From 7346730be98ced2d604ea2376cc4405815dd90a8 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Tue, 14 Apr 2026 07:36:41 +0700 Subject: [PATCH] v7.1.4 --- .github/workflows/build.yml | 102 +-- README.md | 10 +- app.go | 412 +++++++++-- backend/amazon.go | 44 +- backend/artist_format.go | 90 +++ backend/config.go | 17 +- backend/ffmpeg.go | 199 +++-- backend/filemanager.go | 35 + backend/filename.go | 55 +- backend/isrc_finder.go | 424 ++++------- backend/isrc_helper.go | 22 + backend/lyrics.go | 14 +- backend/metadata.go | 170 ++++- backend/musicbrainz.go | 263 +++++-- backend/qobuz.go | 82 ++- backend/qobuz_api.go | 407 +++++++++++ backend/recent_fetches.go | 91 +++ backend/songlink.go | 106 ++- backend/spotfetch.go | 19 +- backend/spotfetch_api.go | 185 ----- backend/spotify_metadata.go | 242 +++++- backend/spotify_totp.go | 28 + backend/tidal.go | 77 +- backend/upc_tags.go | 50 ++ frontend/public/assets/flags/ad.svg | 150 ++++ frontend/public/assets/flags/ae.svg | 6 + frontend/public/assets/flags/af.svg | 81 ++ frontend/public/assets/flags/ag.svg | 14 + frontend/public/assets/flags/ai.svg | 29 + frontend/public/assets/flags/al.svg | 5 + frontend/public/assets/flags/am.svg | 5 + frontend/public/assets/flags/ao.svg | 13 + frontend/public/assets/flags/aq.svg | 5 + frontend/public/assets/flags/ar.svg | 32 + frontend/public/assets/flags/arab.svg | 109 +++ frontend/public/assets/flags/as.svg | 72 ++ frontend/public/assets/flags/asean.svg | 13 + frontend/public/assets/flags/at.svg | 4 + frontend/public/assets/flags/au.svg | 8 + frontend/public/assets/flags/aw.svg | 186 +++++ frontend/public/assets/flags/ax.svg | 18 + frontend/public/assets/flags/az.svg | 8 + frontend/public/assets/flags/ba.svg | 12 + frontend/public/assets/flags/bb.svg | 6 + frontend/public/assets/flags/bd.svg | 4 + frontend/public/assets/flags/be.svg | 7 + frontend/public/assets/flags/bf.svg | 7 + frontend/public/assets/flags/bg.svg | 5 + frontend/public/assets/flags/bh.svg | 4 + frontend/public/assets/flags/bi.svg | 15 + frontend/public/assets/flags/bj.svg | 14 + frontend/public/assets/flags/bl.svg | 5 + frontend/public/assets/flags/bm.svg | 97 +++ frontend/public/assets/flags/bn.svg | 36 + frontend/public/assets/flags/bo.svg | 673 +++++++++++++++++ frontend/public/assets/flags/bq.svg | 5 + frontend/public/assets/flags/br.svg | 45 ++ frontend/public/assets/flags/bs.svg | 13 + frontend/public/assets/flags/bt.svg | 89 +++ frontend/public/assets/flags/bv.svg | 13 + frontend/public/assets/flags/bw.svg | 7 + frontend/public/assets/flags/by.svg | 18 + frontend/public/assets/flags/bz.svg | 145 ++++ frontend/public/assets/flags/ca.svg | 4 + frontend/public/assets/flags/cc.svg | 19 + frontend/public/assets/flags/cd.svg | 5 + frontend/public/assets/flags/cefta.svg | 13 + frontend/public/assets/flags/cf.svg | 15 + frontend/public/assets/flags/cg.svg | 12 + frontend/public/assets/flags/ch.svg | 9 + frontend/public/assets/flags/ci.svg | 7 + frontend/public/assets/flags/ck.svg | 9 + frontend/public/assets/flags/cl.svg | 13 + frontend/public/assets/flags/cm.svg | 15 + frontend/public/assets/flags/cn.svg | 11 + frontend/public/assets/flags/co.svg | 7 + frontend/public/assets/flags/cp.svg | 7 + frontend/public/assets/flags/cr.svg | 7 + frontend/public/assets/flags/cu.svg | 13 + frontend/public/assets/flags/cv.svg | 13 + frontend/public/assets/flags/cw.svg | 14 + frontend/public/assets/flags/cx.svg | 15 + frontend/public/assets/flags/cy.svg | 6 + frontend/public/assets/flags/cz.svg | 5 + frontend/public/assets/flags/de.svg | 5 + frontend/public/assets/flags/dg.svg | 130 ++++ frontend/public/assets/flags/dj.svg | 13 + frontend/public/assets/flags/dk.svg | 5 + frontend/public/assets/flags/dm.svg | 152 ++++ frontend/public/assets/flags/do.svg | 121 +++ frontend/public/assets/flags/dz.svg | 5 + frontend/public/assets/flags/eac.svg | 48 ++ frontend/public/assets/flags/ec.svg | 138 ++++ frontend/public/assets/flags/ee.svg | 5 + frontend/public/assets/flags/eg.svg | 38 + frontend/public/assets/flags/eh.svg | 16 + frontend/public/assets/flags/er.svg | 8 + frontend/public/assets/flags/es-ct.svg | 4 + frontend/public/assets/flags/es-ga.svg | 187 +++++ frontend/public/assets/flags/es-pv.svg | 5 + frontend/public/assets/flags/es.svg | 544 ++++++++++++++ frontend/public/assets/flags/et.svg | 14 + frontend/public/assets/flags/eu.svg | 28 + frontend/public/assets/flags/fi.svg | 5 + frontend/public/assets/flags/fj.svg | 120 +++ frontend/public/assets/flags/fk.svg | 90 +++ frontend/public/assets/flags/fm.svg | 11 + frontend/public/assets/flags/fo.svg | 12 + frontend/public/assets/flags/fr.svg | 5 + frontend/public/assets/flags/ga.svg | 7 + frontend/public/assets/flags/gb-eng.svg | 5 + frontend/public/assets/flags/gb-nir.svg | 132 ++++ frontend/public/assets/flags/gb-sct.svg | 4 + frontend/public/assets/flags/gb-wls.svg | 9 + frontend/public/assets/flags/gb.svg | 7 + frontend/public/assets/flags/gd.svg | 27 + frontend/public/assets/flags/ge.svg | 6 + frontend/public/assets/flags/gf.svg | 5 + frontend/public/assets/flags/gg.svg | 9 + frontend/public/assets/flags/gh.svg | 6 + frontend/public/assets/flags/gi.svg | 32 + frontend/public/assets/flags/gl.svg | 4 + frontend/public/assets/flags/gm.svg | 14 + frontend/public/assets/flags/gn.svg | 7 + frontend/public/assets/flags/gp.svg | 5 + frontend/public/assets/flags/gq.svg | 23 + frontend/public/assets/flags/gr.svg | 16 + frontend/public/assets/flags/gs.svg | 133 ++++ frontend/public/assets/flags/gt.svg | 204 ++++++ frontend/public/assets/flags/gu.svg | 19 + frontend/public/assets/flags/gw.svg | 13 + frontend/public/assets/flags/gy.svg | 9 + frontend/public/assets/flags/hk.svg | 8 + frontend/public/assets/flags/hm.svg | 8 + frontend/public/assets/flags/hn.svg | 18 + frontend/public/assets/flags/hr.svg | 58 ++ frontend/public/assets/flags/ht.svg | 116 +++ frontend/public/assets/flags/hu.svg | 7 + frontend/public/assets/flags/ic.svg | 7 + frontend/public/assets/flags/id.svg | 4 + frontend/public/assets/flags/ie.svg | 7 + frontend/public/assets/flags/il.svg | 14 + frontend/public/assets/flags/im.svg | 36 + frontend/public/assets/flags/in.svg | 25 + frontend/public/assets/flags/io.svg | 130 ++++ frontend/public/assets/flags/iq.svg | 10 + frontend/public/assets/flags/ir.svg | 219 ++++++ frontend/public/assets/flags/is.svg | 12 + frontend/public/assets/flags/it.svg | 7 + frontend/public/assets/flags/je.svg | 62 ++ frontend/public/assets/flags/jm.svg | 8 + frontend/public/assets/flags/jo.svg | 16 + frontend/public/assets/flags/jp.svg | 11 + frontend/public/assets/flags/ke.svg | 23 + frontend/public/assets/flags/kg.svg | 4 + frontend/public/assets/flags/kh.svg | 61 ++ frontend/public/assets/flags/ki.svg | 36 + frontend/public/assets/flags/km.svg | 16 + frontend/public/assets/flags/kn.svg | 14 + frontend/public/assets/flags/kp.svg | 15 + frontend/public/assets/flags/kr.svg | 24 + frontend/public/assets/flags/kw.svg | 13 + frontend/public/assets/flags/ky.svg | 103 +++ frontend/public/assets/flags/kz.svg | 36 + frontend/public/assets/flags/la.svg | 12 + frontend/public/assets/flags/lb.svg | 15 + frontend/public/assets/flags/lc.svg | 8 + frontend/public/assets/flags/li.svg | 43 ++ frontend/public/assets/flags/lk.svg | 22 + frontend/public/assets/flags/lr.svg | 14 + frontend/public/assets/flags/ls.svg | 8 + frontend/public/assets/flags/lt.svg | 7 + frontend/public/assets/flags/lu.svg | 5 + frontend/public/assets/flags/lv.svg | 7 + frontend/public/assets/flags/ly.svg | 13 + frontend/public/assets/flags/ma.svg | 4 + frontend/public/assets/flags/mc.svg | 6 + frontend/public/assets/flags/md.svg | 70 ++ frontend/public/assets/flags/me.svg | 116 +++ frontend/public/assets/flags/mf.svg | 5 + frontend/public/assets/flags/mg.svg | 7 + frontend/public/assets/flags/mh.svg | 7 + frontend/public/assets/flags/mk.svg | 5 + frontend/public/assets/flags/ml.svg | 7 + frontend/public/assets/flags/mm.svg | 12 + frontend/public/assets/flags/mn.svg | 14 + frontend/public/assets/flags/mo.svg | 9 + frontend/public/assets/flags/mp.svg | 86 +++ frontend/public/assets/flags/mq.svg | 5 + frontend/public/assets/flags/mr.svg | 6 + frontend/public/assets/flags/ms.svg | 29 + frontend/public/assets/flags/mt.svg | 58 ++ frontend/public/assets/flags/mu.svg | 8 + frontend/public/assets/flags/mv.svg | 6 + frontend/public/assets/flags/mw.svg | 10 + frontend/public/assets/flags/mx.svg | 382 ++++++++++ frontend/public/assets/flags/my.svg | 26 + frontend/public/assets/flags/mz.svg | 21 + frontend/public/assets/flags/na.svg | 16 + frontend/public/assets/flags/nc.svg | 13 + frontend/public/assets/flags/ne.svg | 6 + frontend/public/assets/flags/nf.svg | 9 + frontend/public/assets/flags/ng.svg | 6 + frontend/public/assets/flags/ni.svg | 129 ++++ frontend/public/assets/flags/nl.svg | 5 + frontend/public/assets/flags/no.svg | 7 + frontend/public/assets/flags/np.svg | 13 + frontend/public/assets/flags/nr.svg | 12 + frontend/public/assets/flags/nu.svg | 10 + frontend/public/assets/flags/nz.svg | 36 + frontend/public/assets/flags/om.svg | 115 +++ frontend/public/assets/flags/pa.svg | 7 + frontend/public/assets/flags/pc.svg | 33 + frontend/public/assets/flags/pe.svg | 4 + frontend/public/assets/flags/pf.svg | 19 + frontend/public/assets/flags/pg.svg | 9 + frontend/public/assets/flags/ph.svg | 6 + frontend/public/assets/flags/pk.svg | 15 + frontend/public/assets/flags/pl.svg | 6 + frontend/public/assets/flags/pm.svg | 5 + frontend/public/assets/flags/pn.svg | 53 ++ frontend/public/assets/flags/pr.svg | 13 + frontend/public/assets/flags/ps.svg | 6 + frontend/public/assets/flags/pt.svg | 57 ++ frontend/public/assets/flags/pw.svg | 11 + frontend/public/assets/flags/py.svg | 157 ++++ frontend/public/assets/flags/qa.svg | 4 + frontend/public/assets/flags/re.svg | 5 + frontend/public/assets/flags/ro.svg | 7 + frontend/public/assets/flags/rs.svg | 292 ++++++++ frontend/public/assets/flags/ru.svg | 5 + frontend/public/assets/flags/rw.svg | 13 + frontend/public/assets/flags/sa.svg | 25 + frontend/public/assets/flags/sb.svg | 13 + frontend/public/assets/flags/sc.svg | 7 + frontend/public/assets/flags/sd.svg | 13 + frontend/public/assets/flags/se.svg | 4 + frontend/public/assets/flags/sg.svg | 13 + frontend/public/assets/flags/sh-ac.svg | 689 ++++++++++++++++++ frontend/public/assets/flags/sh-hl.svg | 164 +++++ frontend/public/assets/flags/sh-ta.svg | 76 ++ frontend/public/assets/flags/sh.svg | 7 + frontend/public/assets/flags/si.svg | 18 + frontend/public/assets/flags/sj.svg | 7 + frontend/public/assets/flags/sk.svg | 9 + frontend/public/assets/flags/sl.svg | 7 + frontend/public/assets/flags/sm.svg | 75 ++ frontend/public/assets/flags/sn.svg | 8 + frontend/public/assets/flags/so.svg | 11 + frontend/public/assets/flags/sr.svg | 6 + frontend/public/assets/flags/ss.svg | 8 + frontend/public/assets/flags/st.svg | 16 + frontend/public/assets/flags/sv.svg | 593 +++++++++++++++ frontend/public/assets/flags/sx.svg | 56 ++ frontend/public/assets/flags/sy.svg | 6 + frontend/public/assets/flags/sz.svg | 34 + frontend/public/assets/flags/tc.svg | 50 ++ frontend/public/assets/flags/td.svg | 7 + frontend/public/assets/flags/tf.svg | 15 + frontend/public/assets/flags/tg.svg | 14 + frontend/public/assets/flags/th.svg | 7 + frontend/public/assets/flags/tj.svg | 22 + frontend/public/assets/flags/tk.svg | 5 + frontend/public/assets/flags/tl.svg | 13 + frontend/public/assets/flags/tm.svg | 204 ++++++ frontend/public/assets/flags/tn.svg | 4 + frontend/public/assets/flags/to.svg | 10 + frontend/public/assets/flags/tr.svg | 8 + frontend/public/assets/flags/tt.svg | 5 + frontend/public/assets/flags/tv.svg | 9 + frontend/public/assets/flags/tw.svg | 34 + frontend/public/assets/flags/tz.svg | 13 + frontend/public/assets/flags/ua.svg | 6 + frontend/public/assets/flags/ug.svg | 30 + frontend/public/assets/flags/um.svg | 9 + frontend/public/assets/flags/un.svg | 16 + frontend/public/assets/flags/us.svg | 9 + frontend/public/assets/flags/uy.svg | 28 + frontend/public/assets/flags/uz.svg | 30 + frontend/public/assets/flags/va.svg | 190 +++++ frontend/public/assets/flags/vc.svg | 8 + frontend/public/assets/flags/ve.svg | 26 + frontend/public/assets/flags/vg.svg | 59 ++ frontend/public/assets/flags/vi.svg | 28 + frontend/public/assets/flags/vn.svg | 11 + frontend/public/assets/flags/vu.svg | 21 + frontend/public/assets/flags/wf.svg | 5 + frontend/public/assets/flags/ws.svg | 7 + frontend/public/assets/flags/xk.svg | 5 + frontend/public/assets/flags/xx.svg | 4 + frontend/public/assets/flags/ye.svg | 7 + frontend/public/assets/flags/yt.svg | 5 + frontend/public/assets/flags/za.svg | 17 + frontend/public/assets/flags/zm.svg | 27 + frontend/public/assets/flags/zw.svg | 21 + frontend/src/App.tsx | 169 +++-- frontend/src/assets/icons/amazon-music.png | Bin 1411 -> 0 bytes frontend/src/assets/icons/amzn.png | Bin 0 -> 56880 bytes frontend/src/assets/icons/lrclib.png | Bin 0 -> 22223 bytes frontend/src/assets/icons/musicbrainz_d.png | Bin 0 -> 28301 bytes frontend/src/assets/icons/musicbrainz_l.png | Bin 0 -> 28243 bytes frontend/src/assets/icons/qbz.png | Bin 0 -> 30704 bytes frontend/src/assets/icons/qobuz.png | Bin 2795 -> 0 bytes frontend/src/assets/icons/songlink.ico | Bin 15086 -> 0 bytes frontend/src/assets/icons/songlink_d.png | Bin 0 -> 15310 bytes frontend/src/assets/icons/songlink_l.png | Bin 0 -> 15477 bytes frontend/src/assets/icons/songstats.png | Bin 12084 -> 40971 bytes frontend/src/assets/icons/tidal.png | Bin 345 -> 0 bytes frontend/src/assets/icons/tidal_d.png | Bin 0 -> 3948 bytes frontend/src/assets/icons/tidal_l.png | Bin 0 -> 3937 bytes frontend/src/components/AboutPage.tsx | 9 +- frontend/src/components/AlbumInfo.tsx | 71 +- frontend/src/components/ApiStatusTab.tsx | 6 +- frontend/src/components/ArtistInfo.tsx | 22 +- frontend/src/components/AvailabilityLinks.tsx | 62 ++ frontend/src/components/FileManagerPage.tsx | 8 +- frontend/src/components/Header.tsx | 2 +- frontend/src/components/PlatformIcons.tsx | 58 +- frontend/src/components/PlaylistInfo.tsx | 26 +- frontend/src/components/SettingsPage.tsx | 34 +- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/components/TitleBar.tsx | 141 +++- frontend/src/components/TrackInfo.tsx | 48 +- frontend/src/components/TrackList.tsx | 68 +- frontend/src/hooks/useApiStatus.ts | 2 +- frontend/src/hooks/useAvailability.ts | 5 +- frontend/src/hooks/useDownload.ts | 34 + frontend/src/hooks/useLyrics.ts | 26 + frontend/src/hooks/useMetadata.ts | 57 +- frontend/src/lib/api-status.ts | 99 ++- frontend/src/lib/api.ts | 8 +- frontend/src/lib/artist-links.ts | 42 ++ frontend/src/lib/playlist.ts | 14 + frontend/src/lib/settings.ts | 27 +- frontend/src/types/api.ts | 15 +- wails.json | 2 +- 336 files changed, 13800 insertions(+), 1142 deletions(-) create mode 100644 backend/artist_format.go create mode 100644 backend/isrc_helper.go create mode 100644 backend/qobuz_api.go create mode 100644 backend/recent_fetches.go delete mode 100644 backend/spotfetch_api.go create mode 100644 backend/spotify_totp.go create mode 100644 backend/upc_tags.go create mode 100644 frontend/public/assets/flags/ad.svg create mode 100644 frontend/public/assets/flags/ae.svg create mode 100644 frontend/public/assets/flags/af.svg create mode 100644 frontend/public/assets/flags/ag.svg create mode 100644 frontend/public/assets/flags/ai.svg create mode 100644 frontend/public/assets/flags/al.svg create mode 100644 frontend/public/assets/flags/am.svg create mode 100644 frontend/public/assets/flags/ao.svg create mode 100644 frontend/public/assets/flags/aq.svg create mode 100644 frontend/public/assets/flags/ar.svg create mode 100644 frontend/public/assets/flags/arab.svg create mode 100644 frontend/public/assets/flags/as.svg create mode 100644 frontend/public/assets/flags/asean.svg create mode 100644 frontend/public/assets/flags/at.svg create mode 100644 frontend/public/assets/flags/au.svg create mode 100644 frontend/public/assets/flags/aw.svg create mode 100644 frontend/public/assets/flags/ax.svg create mode 100644 frontend/public/assets/flags/az.svg create mode 100644 frontend/public/assets/flags/ba.svg create mode 100644 frontend/public/assets/flags/bb.svg create mode 100644 frontend/public/assets/flags/bd.svg create mode 100644 frontend/public/assets/flags/be.svg create mode 100644 frontend/public/assets/flags/bf.svg create mode 100644 frontend/public/assets/flags/bg.svg create mode 100644 frontend/public/assets/flags/bh.svg create mode 100644 frontend/public/assets/flags/bi.svg create mode 100644 frontend/public/assets/flags/bj.svg create mode 100644 frontend/public/assets/flags/bl.svg create mode 100644 frontend/public/assets/flags/bm.svg create mode 100644 frontend/public/assets/flags/bn.svg create mode 100644 frontend/public/assets/flags/bo.svg create mode 100644 frontend/public/assets/flags/bq.svg create mode 100644 frontend/public/assets/flags/br.svg create mode 100644 frontend/public/assets/flags/bs.svg create mode 100644 frontend/public/assets/flags/bt.svg create mode 100644 frontend/public/assets/flags/bv.svg create mode 100644 frontend/public/assets/flags/bw.svg create mode 100644 frontend/public/assets/flags/by.svg create mode 100644 frontend/public/assets/flags/bz.svg create mode 100644 frontend/public/assets/flags/ca.svg create mode 100644 frontend/public/assets/flags/cc.svg create mode 100644 frontend/public/assets/flags/cd.svg create mode 100644 frontend/public/assets/flags/cefta.svg create mode 100644 frontend/public/assets/flags/cf.svg create mode 100644 frontend/public/assets/flags/cg.svg create mode 100644 frontend/public/assets/flags/ch.svg create mode 100644 frontend/public/assets/flags/ci.svg create mode 100644 frontend/public/assets/flags/ck.svg create mode 100644 frontend/public/assets/flags/cl.svg create mode 100644 frontend/public/assets/flags/cm.svg create mode 100644 frontend/public/assets/flags/cn.svg create mode 100644 frontend/public/assets/flags/co.svg create mode 100644 frontend/public/assets/flags/cp.svg create mode 100644 frontend/public/assets/flags/cr.svg create mode 100644 frontend/public/assets/flags/cu.svg create mode 100644 frontend/public/assets/flags/cv.svg create mode 100644 frontend/public/assets/flags/cw.svg create mode 100644 frontend/public/assets/flags/cx.svg create mode 100644 frontend/public/assets/flags/cy.svg create mode 100644 frontend/public/assets/flags/cz.svg create mode 100644 frontend/public/assets/flags/de.svg create mode 100644 frontend/public/assets/flags/dg.svg create mode 100644 frontend/public/assets/flags/dj.svg create mode 100644 frontend/public/assets/flags/dk.svg create mode 100644 frontend/public/assets/flags/dm.svg create mode 100644 frontend/public/assets/flags/do.svg create mode 100644 frontend/public/assets/flags/dz.svg create mode 100644 frontend/public/assets/flags/eac.svg create mode 100644 frontend/public/assets/flags/ec.svg create mode 100644 frontend/public/assets/flags/ee.svg create mode 100644 frontend/public/assets/flags/eg.svg create mode 100644 frontend/public/assets/flags/eh.svg create mode 100644 frontend/public/assets/flags/er.svg create mode 100644 frontend/public/assets/flags/es-ct.svg create mode 100644 frontend/public/assets/flags/es-ga.svg create mode 100644 frontend/public/assets/flags/es-pv.svg create mode 100644 frontend/public/assets/flags/es.svg create mode 100644 frontend/public/assets/flags/et.svg create mode 100644 frontend/public/assets/flags/eu.svg create mode 100644 frontend/public/assets/flags/fi.svg create mode 100644 frontend/public/assets/flags/fj.svg create mode 100644 frontend/public/assets/flags/fk.svg create mode 100644 frontend/public/assets/flags/fm.svg create mode 100644 frontend/public/assets/flags/fo.svg create mode 100644 frontend/public/assets/flags/fr.svg create mode 100644 frontend/public/assets/flags/ga.svg create mode 100644 frontend/public/assets/flags/gb-eng.svg create mode 100644 frontend/public/assets/flags/gb-nir.svg create mode 100644 frontend/public/assets/flags/gb-sct.svg create mode 100644 frontend/public/assets/flags/gb-wls.svg create mode 100644 frontend/public/assets/flags/gb.svg create mode 100644 frontend/public/assets/flags/gd.svg create mode 100644 frontend/public/assets/flags/ge.svg create mode 100644 frontend/public/assets/flags/gf.svg create mode 100644 frontend/public/assets/flags/gg.svg create mode 100644 frontend/public/assets/flags/gh.svg create mode 100644 frontend/public/assets/flags/gi.svg create mode 100644 frontend/public/assets/flags/gl.svg create mode 100644 frontend/public/assets/flags/gm.svg create mode 100644 frontend/public/assets/flags/gn.svg create mode 100644 frontend/public/assets/flags/gp.svg create mode 100644 frontend/public/assets/flags/gq.svg create mode 100644 frontend/public/assets/flags/gr.svg create mode 100644 frontend/public/assets/flags/gs.svg create mode 100644 frontend/public/assets/flags/gt.svg create mode 100644 frontend/public/assets/flags/gu.svg create mode 100644 frontend/public/assets/flags/gw.svg create mode 100644 frontend/public/assets/flags/gy.svg create mode 100644 frontend/public/assets/flags/hk.svg create mode 100644 frontend/public/assets/flags/hm.svg create mode 100644 frontend/public/assets/flags/hn.svg create mode 100644 frontend/public/assets/flags/hr.svg create mode 100644 frontend/public/assets/flags/ht.svg create mode 100644 frontend/public/assets/flags/hu.svg create mode 100644 frontend/public/assets/flags/ic.svg create mode 100644 frontend/public/assets/flags/id.svg create mode 100644 frontend/public/assets/flags/ie.svg create mode 100644 frontend/public/assets/flags/il.svg create mode 100644 frontend/public/assets/flags/im.svg create mode 100644 frontend/public/assets/flags/in.svg create mode 100644 frontend/public/assets/flags/io.svg create mode 100644 frontend/public/assets/flags/iq.svg create mode 100644 frontend/public/assets/flags/ir.svg create mode 100644 frontend/public/assets/flags/is.svg create mode 100644 frontend/public/assets/flags/it.svg create mode 100644 frontend/public/assets/flags/je.svg create mode 100644 frontend/public/assets/flags/jm.svg create mode 100644 frontend/public/assets/flags/jo.svg create mode 100644 frontend/public/assets/flags/jp.svg create mode 100644 frontend/public/assets/flags/ke.svg create mode 100644 frontend/public/assets/flags/kg.svg create mode 100644 frontend/public/assets/flags/kh.svg create mode 100644 frontend/public/assets/flags/ki.svg create mode 100644 frontend/public/assets/flags/km.svg create mode 100644 frontend/public/assets/flags/kn.svg create mode 100644 frontend/public/assets/flags/kp.svg create mode 100644 frontend/public/assets/flags/kr.svg create mode 100644 frontend/public/assets/flags/kw.svg create mode 100644 frontend/public/assets/flags/ky.svg create mode 100644 frontend/public/assets/flags/kz.svg create mode 100644 frontend/public/assets/flags/la.svg create mode 100644 frontend/public/assets/flags/lb.svg create mode 100644 frontend/public/assets/flags/lc.svg create mode 100644 frontend/public/assets/flags/li.svg create mode 100644 frontend/public/assets/flags/lk.svg create mode 100644 frontend/public/assets/flags/lr.svg create mode 100644 frontend/public/assets/flags/ls.svg create mode 100644 frontend/public/assets/flags/lt.svg create mode 100644 frontend/public/assets/flags/lu.svg create mode 100644 frontend/public/assets/flags/lv.svg create mode 100644 frontend/public/assets/flags/ly.svg create mode 100644 frontend/public/assets/flags/ma.svg create mode 100644 frontend/public/assets/flags/mc.svg create mode 100644 frontend/public/assets/flags/md.svg create mode 100644 frontend/public/assets/flags/me.svg create mode 100644 frontend/public/assets/flags/mf.svg create mode 100644 frontend/public/assets/flags/mg.svg create mode 100644 frontend/public/assets/flags/mh.svg create mode 100644 frontend/public/assets/flags/mk.svg create mode 100644 frontend/public/assets/flags/ml.svg create mode 100644 frontend/public/assets/flags/mm.svg create mode 100644 frontend/public/assets/flags/mn.svg create mode 100644 frontend/public/assets/flags/mo.svg create mode 100644 frontend/public/assets/flags/mp.svg create mode 100644 frontend/public/assets/flags/mq.svg create mode 100644 frontend/public/assets/flags/mr.svg create mode 100644 frontend/public/assets/flags/ms.svg create mode 100644 frontend/public/assets/flags/mt.svg create mode 100644 frontend/public/assets/flags/mu.svg create mode 100644 frontend/public/assets/flags/mv.svg create mode 100644 frontend/public/assets/flags/mw.svg create mode 100644 frontend/public/assets/flags/mx.svg create mode 100644 frontend/public/assets/flags/my.svg create mode 100644 frontend/public/assets/flags/mz.svg create mode 100644 frontend/public/assets/flags/na.svg create mode 100644 frontend/public/assets/flags/nc.svg create mode 100644 frontend/public/assets/flags/ne.svg create mode 100644 frontend/public/assets/flags/nf.svg create mode 100644 frontend/public/assets/flags/ng.svg create mode 100644 frontend/public/assets/flags/ni.svg create mode 100644 frontend/public/assets/flags/nl.svg create mode 100644 frontend/public/assets/flags/no.svg create mode 100644 frontend/public/assets/flags/np.svg create mode 100644 frontend/public/assets/flags/nr.svg create mode 100644 frontend/public/assets/flags/nu.svg create mode 100644 frontend/public/assets/flags/nz.svg create mode 100644 frontend/public/assets/flags/om.svg create mode 100644 frontend/public/assets/flags/pa.svg create mode 100644 frontend/public/assets/flags/pc.svg create mode 100644 frontend/public/assets/flags/pe.svg create mode 100644 frontend/public/assets/flags/pf.svg create mode 100644 frontend/public/assets/flags/pg.svg create mode 100644 frontend/public/assets/flags/ph.svg create mode 100644 frontend/public/assets/flags/pk.svg create mode 100644 frontend/public/assets/flags/pl.svg create mode 100644 frontend/public/assets/flags/pm.svg create mode 100644 frontend/public/assets/flags/pn.svg create mode 100644 frontend/public/assets/flags/pr.svg create mode 100644 frontend/public/assets/flags/ps.svg create mode 100644 frontend/public/assets/flags/pt.svg create mode 100644 frontend/public/assets/flags/pw.svg create mode 100644 frontend/public/assets/flags/py.svg create mode 100644 frontend/public/assets/flags/qa.svg create mode 100644 frontend/public/assets/flags/re.svg create mode 100644 frontend/public/assets/flags/ro.svg create mode 100644 frontend/public/assets/flags/rs.svg create mode 100644 frontend/public/assets/flags/ru.svg create mode 100644 frontend/public/assets/flags/rw.svg create mode 100644 frontend/public/assets/flags/sa.svg create mode 100644 frontend/public/assets/flags/sb.svg create mode 100644 frontend/public/assets/flags/sc.svg create mode 100644 frontend/public/assets/flags/sd.svg create mode 100644 frontend/public/assets/flags/se.svg create mode 100644 frontend/public/assets/flags/sg.svg create mode 100644 frontend/public/assets/flags/sh-ac.svg create mode 100644 frontend/public/assets/flags/sh-hl.svg create mode 100644 frontend/public/assets/flags/sh-ta.svg create mode 100644 frontend/public/assets/flags/sh.svg create mode 100644 frontend/public/assets/flags/si.svg create mode 100644 frontend/public/assets/flags/sj.svg create mode 100644 frontend/public/assets/flags/sk.svg create mode 100644 frontend/public/assets/flags/sl.svg create mode 100644 frontend/public/assets/flags/sm.svg create mode 100644 frontend/public/assets/flags/sn.svg create mode 100644 frontend/public/assets/flags/so.svg create mode 100644 frontend/public/assets/flags/sr.svg create mode 100644 frontend/public/assets/flags/ss.svg create mode 100644 frontend/public/assets/flags/st.svg create mode 100644 frontend/public/assets/flags/sv.svg create mode 100644 frontend/public/assets/flags/sx.svg create mode 100644 frontend/public/assets/flags/sy.svg create mode 100644 frontend/public/assets/flags/sz.svg create mode 100644 frontend/public/assets/flags/tc.svg create mode 100644 frontend/public/assets/flags/td.svg create mode 100644 frontend/public/assets/flags/tf.svg create mode 100644 frontend/public/assets/flags/tg.svg create mode 100644 frontend/public/assets/flags/th.svg create mode 100644 frontend/public/assets/flags/tj.svg create mode 100644 frontend/public/assets/flags/tk.svg create mode 100644 frontend/public/assets/flags/tl.svg create mode 100644 frontend/public/assets/flags/tm.svg create mode 100644 frontend/public/assets/flags/tn.svg create mode 100644 frontend/public/assets/flags/to.svg create mode 100644 frontend/public/assets/flags/tr.svg create mode 100644 frontend/public/assets/flags/tt.svg create mode 100644 frontend/public/assets/flags/tv.svg create mode 100644 frontend/public/assets/flags/tw.svg create mode 100644 frontend/public/assets/flags/tz.svg create mode 100644 frontend/public/assets/flags/ua.svg create mode 100644 frontend/public/assets/flags/ug.svg create mode 100644 frontend/public/assets/flags/um.svg create mode 100644 frontend/public/assets/flags/un.svg create mode 100644 frontend/public/assets/flags/us.svg create mode 100644 frontend/public/assets/flags/uy.svg create mode 100644 frontend/public/assets/flags/uz.svg create mode 100644 frontend/public/assets/flags/va.svg create mode 100644 frontend/public/assets/flags/vc.svg create mode 100644 frontend/public/assets/flags/ve.svg create mode 100644 frontend/public/assets/flags/vg.svg create mode 100644 frontend/public/assets/flags/vi.svg create mode 100644 frontend/public/assets/flags/vn.svg create mode 100644 frontend/public/assets/flags/vu.svg create mode 100644 frontend/public/assets/flags/wf.svg create mode 100644 frontend/public/assets/flags/ws.svg create mode 100644 frontend/public/assets/flags/xk.svg create mode 100644 frontend/public/assets/flags/xx.svg create mode 100644 frontend/public/assets/flags/ye.svg create mode 100644 frontend/public/assets/flags/yt.svg create mode 100644 frontend/public/assets/flags/za.svg create mode 100644 frontend/public/assets/flags/zm.svg create mode 100644 frontend/public/assets/flags/zw.svg delete mode 100644 frontend/src/assets/icons/amazon-music.png create mode 100644 frontend/src/assets/icons/amzn.png create mode 100644 frontend/src/assets/icons/lrclib.png create mode 100644 frontend/src/assets/icons/musicbrainz_d.png create mode 100644 frontend/src/assets/icons/musicbrainz_l.png create mode 100644 frontend/src/assets/icons/qbz.png delete mode 100644 frontend/src/assets/icons/qobuz.png delete mode 100644 frontend/src/assets/icons/songlink.ico create mode 100644 frontend/src/assets/icons/songlink_d.png create mode 100644 frontend/src/assets/icons/songlink_l.png delete mode 100644 frontend/src/assets/icons/tidal.png create mode 100644 frontend/src/assets/icons/tidal_d.png create mode 100644 frontend/src/assets/icons/tidal_l.png create mode 100644 frontend/src/components/AvailabilityLinks.tsx create mode 100644 frontend/src/lib/artist-links.ts create mode 100644 frontend/src/lib/playlist.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e09dd5..66ca432 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,13 +81,13 @@ jobs: - name: Prepare artifacts run: | mkdir -p dist - Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe" + Compress-Archive -Path "build\bin\SpotiFLAC.exe" -DestinationPath "dist\spotiflac-windows.zip" -Force - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: windows-portable - path: dist/SpotiFLAC.exe + name: windows-bundle + path: dist/spotiflac-windows.zip retention-days: 7 build-macos: @@ -147,36 +147,33 @@ jobs: - name: Build application run: wails build -platform darwin/universal - - name: Create DMG + - name: Create macOS bundle run: | mkdir -p dist - # Install create-dmg if not available - brew install create-dmg || true - - # Create DMG - create-dmg \ - --volname "SpotiFLAC" \ - --window-pos 200 120 \ - --window-size 600 400 \ - --icon-size 100 \ - --icon "SpotiFLAC.app" 175 120 \ - --hide-extension "SpotiFLAC.app" \ - --app-drop-link 425 120 \ - "dist/SpotiFLAC.dmg" \ - "build/bin/SpotiFLAC.app" || \ - # Fallback to hdiutil if create-dmg fails - hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg + ditto -c -k --sequesterRsrc --keepParent "build/bin/SpotiFLAC.app" "dist/spotiflac-macos-bundle.zip" - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: macos-portable - path: dist/SpotiFLAC.dmg + name: macos-bundle + path: dist/spotiflac-macos-bundle.zip retention-days: 7 build-linux: - name: Build Linux - runs-on: ubuntu-24.04 + name: Build Linux (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + goarch: amd64 + runner: ubuntu-24.04 + appimage_arch: x86_64 + - arch: arm64 + goarch: arm64 + runner: ubuntu-24.04-arm + appimage_arch: aarch64 steps: - name: Checkout code uses: actions/checkout@v4 @@ -222,10 +219,15 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl + PACKAGES="libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick" + if [ "${{ matrix.goarch }}" = "amd64" ]; then + PACKAGES="$PACKAGES upx-ucl" + fi + sudo apt-get install -y $PACKAGES # Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility) - sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc + MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" + sudo ln -sf "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.1.pc" "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.0.pc" - name: Install Wails CLI run: go install github.com/wailsapp/wails/v2/cmd/wails@latest @@ -237,9 +239,10 @@ jobs: pnpm run generate-icon - name: Build application - run: wails build -platform linux/amd64 + run: wails build -platform linux/${{ matrix.goarch }} - name: Compress with UPX + if: matrix.goarch == 'amd64' run: | upx --best --lzma build/bin/SpotiFLAC @@ -248,13 +251,13 @@ jobs: uses: actions/cache@v4 with: path: appimagetool - key: appimagetool-x86_64-v1 + key: appimagetool-${{ matrix.appimage_arch }}-v1 - name: Download appimagetool if: steps.cache-appimagetool.outputs.cache-hit != 'true' run: | - wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \ - wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage + wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${{ matrix.appimage_arch }}.AppImage || \ + wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.appimage_arch }}.AppImage - name: Make appimagetool executable run: chmod +x appimagetool @@ -309,13 +312,18 @@ jobs: # Create AppImage mkdir -p dist - ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage + if [ "${{ matrix.goarch }}" = "arm64" ]; then + RELEASE_ARCH="arm64v8" + else + RELEASE_ARCH="amd64" + fi + ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/SpotiFLAC-${RELEASE_ARCH}.AppImage" - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: linux-portable - path: dist/SpotiFLAC.AppImage + name: linux-appimage-${{ matrix.arch }} + path: dist/*.AppImage retention-days: 7 create-release: @@ -343,6 +351,13 @@ jobs: - name: Display structure of downloaded files run: ls -R artifacts + - name: Create Linux bundle + run: | + mkdir -p release/SpotiFLAC-linux-bundle + cp artifacts/linux-appimage-amd64/*.AppImage release/SpotiFLAC-linux-bundle/ + cp artifacts/linux-appimage-arm64/*.AppImage release/SpotiFLAC-linux-bundle/ + tar -czf release/spotiflac-linux-bundle.tar.gz -C release SpotiFLAC-linux-bundle + - name: Create Release uses: softprops/action-gh-release@v2 with: @@ -354,15 +369,20 @@ jobs: ## Downloads - - `SpotiFLAC.exe` - Windows - - `SpotiFLAC.dmg` - macOS - - `SpotiFLAC.AppImage` - Linux + - `spotiflac-windows.zip` - amd64 + - `spotiflac-macos-bundle.zip` - amd64 + arm64 + - `spotiflac-linux-bundle.tar.gz` - amd64 + arm64v8
Linux Requirements The AppImage requires `webkit2gtk-4.1` to be installed on your system: + Choose the correct AppImage after extracting the bundle: + + - `SpotiFLAC-amd64.AppImage` - amd64 + - `SpotiFLAC-arm64v8.AppImage` - arm64v8 + **Ubuntu/Debian:** ```bash sudo apt install libwebkit2gtk-4.1-0 @@ -380,14 +400,14 @@ jobs: After installing the dependency, make the AppImage executable: ```bash - chmod +x SpotiFLAC.AppImage - ./SpotiFLAC.AppImage + tar -xzf spotiflac-linux-bundle.tar.gz + chmod +x SpotiFLAC-*.AppImage ```
files: | - artifacts/windows-portable/*.exe - artifacts/macos-portable/*.dmg - artifacts/linux-portable/*.AppImage + artifacts/windows-bundle/*.zip + artifacts/macos-bundle/*.zip + release/spotiflac-linux-bundle.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 2261677..d3604ab 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,23 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account [![Announcements](https://img.shields.io/badge/ANNOUNCEMENTS-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) [![Chat](https://img.shields.io/badge/CHAT-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) -### [Download](https://github.com/afkarxyz/SpotiFLAC/releases) +### [Download](https://github.com/spotbye/SpotiFLAC/releases) ![Image](https://github.com/user-attachments/assets/c2624ca5-8569-49f0-950e-4410b523cea1) ## Other projects -### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next) +### [SpotiFLAC Next](https://github.com/spotbye/SpotiFLAC-Next) Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required. -### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) +### [SpotiDownloader](https://github.com/spotbye/SpotiDownloader) Get Spotify tracks, albums, playlists and discography in MP3 and FLAC. -### [SpotubeDL](https://spotubedl.com) +### [SpotubeDL.com](https://spotubedl.com) -Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality. +Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus. ### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile) diff --git a/app.go b/app.go index 05c24d7..998b7c5 100644 --- a/app.go +++ b/app.go @@ -14,6 +14,7 @@ import ( "net/http" "strings" + "sync" "time" "github.com/afkarxyz/SpotiFLAC/backend" @@ -25,7 +26,22 @@ type App struct { ctx context.Context } +type CurrentIPInfo struct { + IP string `json:"ip"` + Country string `json:"country"` + CountryCode string `json:"country_code,omitempty"` + Source string `json:"source,omitempty"` +} + const checkOperationTimeout = 10 * time.Second +const unifiedStatusAPIURL = "https://api-status.afkarxyz.qzz.io/api/status/spotiflac/" +const unifiedStatusCacheTTL = 5 * time.Second + +var ( + unifiedStatusCacheMu sync.Mutex + unifiedStatusCacheBody string + unifiedStatusCacheExpiry time.Time +) func NewApp() *App { return &App{} @@ -78,6 +94,42 @@ func containsStreamingURL(body []byte) bool { return isStreamingURL(trimmedBody) } +func containsLRCLIBResults(body []byte) bool { + trimmedBody := strings.TrimSpace(string(body)) + if trimmedBody == "" { + return false + } + + var searchResults []map[string]interface{} + if err := json.Unmarshal(body, &searchResults); err == nil { + return len(searchResults) > 0 + } + + var exactResult map[string]interface{} + if err := json.Unmarshal(body, &exactResult); err == nil { + return len(exactResult) > 0 + } + + return false +} + +func containsMusicBrainzResults(body []byte) bool { + trimmedBody := strings.TrimSpace(string(body)) + if trimmedBody == "" { + return false + } + + var payload struct { + Count int `json:"count"` + Recordings []json.RawMessage `json:"recordings"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return false + } + + return payload.Count > 0 || len(payload.Recordings) > 0 +} + func isStreamingURL(raw string) bool { candidate := strings.TrimSpace(raw) if candidate == "" { @@ -92,6 +144,179 @@ func isStreamingURL(raw string) bool { return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" } +func previewResponseBody(body []byte, maxLen int) string { + preview := strings.TrimSpace(string(body)) + if maxLen > 0 && len(preview) > maxLen { + return preview[:maxLen] + "..." + } + return preview +} + +func fetchUnifiedStatusPayload(forceRefresh bool, endpoint string) (string, error) { + unifiedStatusCacheMu.Lock() + defer unifiedStatusCacheMu.Unlock() + + if !forceRefresh && unifiedStatusCacheBody != "" && time.Now().Before(unifiedStatusCacheExpiry) { + return unifiedStatusCacheBody, nil + } + + client := &http.Client{Timeout: 10 * time.Second} + maxRetries := 3 + var lastErr error + + for i := 0; i < maxRetries; i++ { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("failed to create unified status request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err == nil { + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = fmt.Errorf("attempt %d: failed reading response: %w", i+1, readErr) + } else if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("attempt %d: returned status %d (%s)", i+1, resp.StatusCode, previewResponseBody(body, 200)) + } else { + payload := strings.TrimSpace(string(body)) + if payload == "" { + lastErr = fmt.Errorf("attempt %d: empty response body", i+1) + } else { + unifiedStatusCacheBody = payload + unifiedStatusCacheExpiry = time.Now().Add(unifiedStatusCacheTTL) + return payload, nil + } + } + } else { + lastErr = fmt.Errorf("attempt %d: connection failed: %w", i+1, err) + } + + if i < maxRetries-1 { + time.Sleep(1 * time.Second) + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("unknown error") + } + + return "", fmt.Errorf("unified status API failed after %d retries: %w", maxRetries, lastErr) +} + +func fetchCurrentIPInfo() (CurrentIPInfo, error) { + type ipwhoisResponse struct { + Success bool `json:"success"` + IP string `json:"ip"` + Country string `json:"country"` + CountryCode string `json:"country_code"` + Message string `json:"message"` + } + type ipapiResponse struct { + IP string `json:"ip"` + Country string `json:"country_name"` + CountryCode string `json:"country_code"` + Error bool `json:"error"` + Reason string `json:"reason"` + } + + client := &http.Client{Timeout: 8 * time.Second} + tryFetch := func(source, reqURL string, parse func(body []byte) (CurrentIPInfo, error)) (CurrentIPInfo, error) { + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return CurrentIPInfo{}, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return CurrentIPInfo{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return CurrentIPInfo{}, err + } + if resp.StatusCode != http.StatusOK { + return CurrentIPInfo{}, fmt.Errorf("%s returned status %d: %s", source, resp.StatusCode, previewResponseBody(body, 200)) + } + + info, err := parse(body) + if err != nil { + return CurrentIPInfo{}, err + } + info.Source = source + return info, nil + } + + info, err := tryFetch("ipwho.is", "https://ipwho.is/", func(body []byte) (CurrentIPInfo, error) { + var payload ipwhoisResponse + if err := json.Unmarshal(body, &payload); err != nil { + return CurrentIPInfo{}, err + } + if !payload.Success { + return CurrentIPInfo{}, fmt.Errorf("ipwho.is lookup failed: %s", strings.TrimSpace(payload.Message)) + } + if strings.TrimSpace(payload.IP) == "" || strings.TrimSpace(payload.Country) == "" { + return CurrentIPInfo{}, fmt.Errorf("ipwho.is returned incomplete IP data") + } + return CurrentIPInfo{ + IP: strings.TrimSpace(payload.IP), + Country: strings.TrimSpace(payload.Country), + CountryCode: strings.TrimSpace(payload.CountryCode), + }, nil + }) + if err == nil { + return info, nil + } + firstErr := err + + info, err = tryFetch("ipapi.co", "https://ipapi.co/json/", func(body []byte) (CurrentIPInfo, error) { + var payload ipapiResponse + if err := json.Unmarshal(body, &payload); err != nil { + return CurrentIPInfo{}, err + } + if payload.Error { + return CurrentIPInfo{}, fmt.Errorf("ipapi.co lookup failed: %s", strings.TrimSpace(payload.Reason)) + } + if strings.TrimSpace(payload.IP) == "" || strings.TrimSpace(payload.Country) == "" { + return CurrentIPInfo{}, fmt.Errorf("ipapi.co returned incomplete IP data") + } + return CurrentIPInfo{ + IP: strings.TrimSpace(payload.IP), + Country: strings.TrimSpace(payload.Country), + CountryCode: strings.TrimSpace(payload.CountryCode), + }, nil + }) + if err == nil { + return info, nil + } + + return CurrentIPInfo{}, fmt.Errorf("failed to detect public IP: %v; fallback failed: %v", firstErr, err) +} + +func (a *App) GetCurrentIPInfo() (string, error) { + info, err := fetchCurrentIPInfo() + if err != nil { + return "", err + } + + payload, err := json.Marshal(info) + if err != nil { + return "", err + } + + return string(payload), nil +} + +func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) { + return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL) +} + func (a *App) getFirstArtist(artistString string) string { if artistString == "" { return "" @@ -142,7 +367,7 @@ type DownloadRequest struct { AlbumArtist string `json:"album_artist,omitempty"` ReleaseDate string `json:"release_date,omitempty"` CoverURL string `json:"cover_url,omitempty"` - ApiURL string `json:"api_url,omitempty"` + TidalAPIURL string `json:"tidal_api_url,omitempty"` OutputDir string `json:"output_dir,omitempty"` AudioFormat string `json:"audio_format,omitempty"` FilenameFormat string `json:"filename_format,omitempty"` @@ -159,8 +384,10 @@ type DownloadRequest struct { SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"` + ISRC string `json:"isrc,omitempty"` Copyright string `json:"copyright,omitempty"` Publisher string `json:"publisher,omitempty"` + Composer string `json:"composer,omitempty"` PlaylistName string `json:"playlist_name,omitempty"` PlaylistOwner string `json:"playlist_owner,omitempty"` AllowFallback bool `json:"allow_fallback"` @@ -245,27 +472,6 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { } } - if err == nil && settings != nil { - if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI { - if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" { - - data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) { - runtime.EventsEmit(a.ctx, "metadata-stream", tracks) - }) - if err != nil { - return "", fmt.Errorf("failed to fetch metadata from API: %v", err) - } - - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } - - return string(jsonData), nil - } - } - } - data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) { runtime.EventsEmit(a.ctx, "metadata-stream", tracks) }) @@ -362,6 +568,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if req.FilenameFormat == "" { req.FilenameFormat = "title-artist" } + if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" { + req.ISRC = backend.ResolveTrackISRC(req.SpotifyID) + } itemID := req.ItemID if itemID == "" { @@ -384,25 +593,26 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) } - if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) { + metadataSeparator := req.Separator + if metadataSeparator == "" { + metadataSeparator = ", " + metadataSettings, _ := a.LoadSettings() + if metadataSettings != nil { + if sep, ok := metadataSettings["separator"].(string); ok { + if sep == "semicolon" { + metadataSeparator = "; " + } else if sep == "comma" { + metadataSeparator = ", " + } + } + } + } + + if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.Composer == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) - metadataSeparator := req.Separator - if metadataSeparator == "" { - metadataSeparator = ", " - metadataSettings, _ := a.LoadSettings() - if metadataSettings != nil { - if sep, ok := metadataSettings["separator"].(string); ok { - if sep == "semicolon" { - metadataSeparator = "; " - } else if sep == "comma" { - metadataSeparator = ", " - } - } - } - } trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil) if err == nil { @@ -410,6 +620,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Track struct { Copyright string `json:"copyright"` Publisher string `json:"publisher"` + Composer string `json:"composer"` TotalDiscs int `json:"total_discs"` TotalTracks int `json:"total_tracks"` TrackNumber int `json:"track_number"` @@ -425,6 +636,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if req.Publisher == "" && trackResp.Track.Publisher != "" { req.Publisher = trackResp.Track.Publisher } + if req.Composer == "" && trackResp.Track.Composer != "" { + req.Composer = trackResp.Track.Composer + } if req.SpotifyTotalDiscs == 0 && trackResp.Track.TotalDiscs > 0 { req.SpotifyTotalDiscs = trackResp.Track.TotalDiscs } @@ -443,19 +657,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } if req.TrackName != "" && req.ArtistName != "" { - expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber) + expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber, req.ISRC) expectedPath := filepath.Join(req.OutputDir, expectedFilename) - if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { + if !backend.GetRedownloadWithSuffixSetting() { + if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { - backend.SkipDownloadItem(itemID, expectedPath) - return DownloadResponse{ - Success: true, - Message: "File already exists", - File: expectedPath, - AlreadyExists: true, - ItemID: itemID, - }, nil + backend.SkipDownloadItem(itemID, expectedPath) + return DownloadResponse{ + Success: true, + Message: "File already exists", + File: expectedPath, + AlreadyExists: true, + ItemID: itemID, + }, nil + } } } @@ -500,38 +716,41 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } case "tidal": - if req.ApiURL == "" || req.ApiURL == "auto" { + if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" { downloader := backend.NewTidalDownloader("") if req.ServiceURL != "" { - filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } } else { - downloader := backend.NewTidalDownloader(req.ApiURL) + downloader := backend.NewTidalDownloader(req.TidalAPIURL) if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } else { - filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) } } case "qobuz": - fmt.Println("Waiting for ISRC (Qobuz dependency)...") - isrc := <-isrcChan + isrc := strings.TrimSpace(req.ISRC) + if isrc == "" { + fmt.Println("Waiting for ISRC (Qobuz dependency)...") + isrc = <-isrcChan + } downloader := backend.NewQobuzDownloader() quality := req.AudioFormat if quality == "" { quality = "6" } - filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) default: return DownloadResponse{ @@ -832,6 +1051,10 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL) } else if apiType == "amazon" { checkURL = fmt.Sprintf("%s/status", apiURL) + } else if apiType == "lrclib" { + checkURL = fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", strings.TrimRight(apiURL, "/")) + } else if apiType == "musicbrainz" { + checkURL = fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", strings.TrimRight(apiURL, "/"), url.QueryEscape(`recording:"Hello" AND artist:"Adele"`)) } else { checkURL = apiURL } @@ -842,6 +1065,7 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { return false, err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36") + req.Header.Set("Accept", "application/json") maxRetries := 3 for i := 0; i < maxRetries; i++ { @@ -865,7 +1089,15 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { return true, nil } - if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && statusCode == 200 { + if apiType == "lrclib" && statusCode == 200 && containsLRCLIBResults(body) { + return true, nil + } + + if apiType == "musicbrainz" && statusCode == 200 && containsMusicBrainzResults(body) { + return true, nil + } + + if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && apiType != "lrclib" && apiType != "musicbrainz" && statusCode == 200 { return true, nil } } @@ -876,10 +1108,17 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { return false, nil }) if err != nil { + if apiType == "musicbrainz" { + backend.SetMusicBrainzStatusCheckResult(false) + } fmt.Printf("CheckAPIStatus timeout/error for %s (%s): %v\n", apiType, apiURL, err) return false } + if apiType == "musicbrainz" { + backend.SetMusicBrainzStatusCheckResult(isOnline) + } + return isOnline } @@ -920,6 +1159,34 @@ func (a *App) ClearFetchHistoryByType(itemType string) error { return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC") } +func (a *App) GetRecentFetches() (string, error) { + items, err := backend.LoadRecentFetches() + if err != nil { + return "", err + } + + data, err := json.Marshal(items) + if err != nil { + return "", err + } + + return string(data), nil +} + +func (a *App) SaveRecentFetches(payload string) error { + payload = strings.TrimSpace(payload) + if payload == "" { + payload = "[]" + } + + var items []backend.RecentFetchItem + if err := json.Unmarshal([]byte(payload), &items); err != nil { + return err + } + + return backend.SaveRecentFetches(items) +} + func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) { if audioFilePath == "" || base64Data == "" { return "", fmt.Errorf("file path and image data are required") @@ -951,6 +1218,7 @@ type LyricsDownloadRequest struct { AlbumName string `json:"album_name"` AlbumArtist string `json:"album_artist"` ReleaseDate string `json:"release_date"` + ISRC string `json:"isrc,omitempty"` OutputDir string `json:"output_dir"` FilenameFormat string `json:"filename_format"` TrackNumber bool `json:"track_number"` @@ -975,6 +1243,7 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR AlbumName: req.AlbumName, AlbumArtist: req.AlbumArtist, ReleaseDate: req.ReleaseDate, + ISRC: req.ISRC, OutputDir: req.OutputDir, FilenameFormat: req.FilenameFormat, TrackNumber: req.TrackNumber, @@ -1398,6 +1667,7 @@ type CheckFileExistenceRequest struct { AlbumName string `json:"album_name,omitempty"` AlbumArtist string `json:"album_artist,omitempty"` ReleaseDate string `json:"release_date,omitempty"` + ISRC string `json:"isrc,omitempty"` TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"` Position int `json:"position,omitempty"` @@ -1427,6 +1697,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che } defaultFilenameFormat := "title-artist" + redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting() type result struct { index int @@ -1477,6 +1748,10 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che if filenameFormat == "" { filenameFormat = defaultFilenameFormat } + isrc := strings.TrimSpace(t.ISRC) + if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" { + isrc = backend.ResolveTrackISRC(t.SpotifyID) + } trackNumber := t.Position if t.UseAlbumTrackNumber && t.TrackNumber > 0 { @@ -1501,6 +1776,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che trackNumber, t.DiscNumber, t.UseAlbumTrackNumber, + isrc, ) expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt @@ -1511,13 +1787,17 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che } expectedPath := filepath.Join(targetDir, expectedFilename) - - if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { - res.Exists = true - res.FilePath = expectedPath + if redownloadWithSuffix { + expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true) + res.FilePath = filepath.Base(expectedPath) } else { + if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { + res.Exists = true + res.FilePath = expectedPath + } else { - res.FilePath = expectedFilename + res.FilePath = expectedFilename + } } resultsChan <- result{index: idx, result: res} @@ -1567,6 +1847,10 @@ func (a *App) SkipDownloadItem(itemID, filePath string) { backend.SkipDownloadItem(itemID, filePath) } +func (a *App) GetTrackISRC(spotifyTrackID string) string { + return backend.ResolveTrackISRC(spotifyTrackID) +} + func (a *App) GetPreviewURL(trackID string) (string, error) { return backend.GetPreviewURL(trackID) } diff --git a/backend/amazon.go b/backend/amazon.go index a3484c5..679f16b 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -204,7 +204,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -219,12 +219,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename filenameArtist = GetFirstArtist(spotifyArtistName) filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist) } - expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false) + expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false, isrcOverride) expectedPath := filepath.Join(outputDir, expectedFilename) - if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024)) - return "EXISTS:" + expectedPath, nil + if !GetRedownloadWithSuffixSetting() { + if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { + fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024)) + return "EXISTS:" + expectedPath, nil + } } } @@ -250,12 +252,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } res.ISRC = isrc if isrc != "" { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + if ShouldSkipMusicBrainzMetadataFetch() { + fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") } else { - fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } } } metaChan <- res @@ -271,11 +277,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename return "", err } - var isrc string + isrc := strings.TrimSpace(isrcOverride) var mbMeta Metadata if spotifyURL != "" { result := <-metaChan - isrc = result.ISRC + if isrc == "" { + isrc = result.ISRC + } mbMeta = result.Metadata } @@ -309,6 +317,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) newFilename = strings.ReplaceAll(newFilename, "{year}", year) newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate)) + newFilename = strings.ReplaceAll(newFilename, "{isrc}", SanitizeOptionalFilename(isrc)) if spotifyDiscNumber > 0 { newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) @@ -346,6 +355,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } newFilename = newFilename + ext newFilePath := filepath.Join(outputDir, newFilename) + if GetRedownloadWithSuffixSetting() { + newFilePath, _ = ResolveOutputPathForDownload(newFilePath, true) + } if err := os.Rename(filePath, newFilePath); err != nil { fmt.Printf("Warning: Failed to rename file: %v\n", err) @@ -390,7 +402,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, - Description: "https://github.com/afkarxyz/SpotiFLAC", + Composer: spotifyComposer, + Separator: metadataSeparator, + Description: "https://github.com/spotbye/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, } @@ -418,7 +432,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename return filePath, nil } -func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool, ) (string, error) { @@ -427,5 +441,5 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, qualit return "", err } - return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre) } diff --git a/backend/artist_format.go b/backend/artist_format.go new file mode 100644 index 0000000..29c4f26 --- /dev/null +++ b/backend/artist_format.go @@ -0,0 +1,90 @@ +package backend + +import "strings" + +func normalizeArtistSeparator(separator string) string { + separator = strings.TrimSpace(separator) + if separator == "," || separator == ";" { + return separator + } + return "" +} + +func splitArtistSegment(segment string, separator string) []string { + segment = strings.TrimSpace(segment) + if segment == "" { + return nil + } + + if strings.Contains(segment, "|||SEP|||") { + return strings.Split(segment, "|||SEP|||") + } + + parts := []string{segment} + + if separator = normalizeArtistSeparator(separator); separator != "" { + var separated []string + for _, part := range parts { + for _, item := range strings.Split(part, separator) { + separated = append(separated, item) + } + } + parts = separated + } else if strings.Contains(segment, ";") { + var separated []string + for _, part := range parts { + for _, item := range strings.Split(part, ";") { + separated = append(separated, item) + } + } + parts = separated + } + + return parts +} + +func SplitArtistCredits(artistStr, separator string) []string { + rawParts := splitArtistSegment(artistStr, separator) + if len(rawParts) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(rawParts)) + result := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, exists := seen[part]; exists { + continue + } + seen[part] = struct{}{} + result = append(result, part) + } + + return result +} + +func SplitMetadataValues(value, separator string) []string { + rawParts := splitArtistSegment(value, separator) + if len(rawParts) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(rawParts)) + result := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, exists := seen[part]; exists { + continue + } + seen[part] = struct{}{} + result = append(result, part) + } + + return result +} diff --git a/backend/config.go b/backend/config.go index 5f1b562..e407cb3 100644 --- a/backend/config.go +++ b/backend/config.go @@ -50,23 +50,14 @@ func LoadConfigSettings() (map[string]interface{}, error) { return settings, nil } -func GetSpotFetchAPISettings() (bool, string) { +func GetRedownloadWithSuffixSetting() bool { settings, err := LoadConfigSettings() if err != nil || settings == nil { - return false, "" + return false } - useAPI, _ := settings["useSpotFetchAPI"].(bool) - if !useAPI { - return false, "" - } - - apiURL, _ := settings["spotFetchAPIUrl"].(string) - if apiURL == "" { - apiURL = "https://sp.afkarxyz.qzz.io/api" - } - - return true, apiURL + enabled, _ := settings["redownloadWithSuffix"].(bool) + return enabled } func GetLinkResolverSetting() string { diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index b9cd3e3..f228bb4 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -83,6 +83,37 @@ func GetFFmpegDir() (string, error) { return EnsureAppDir() } +func resolveSystemExecutable(executableName string) string { + if runtime.GOOS == "darwin" { + candidates := []string{ + "/opt/homebrew/bin/" + executableName, + "/usr/local/bin/" + executableName, + } + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + } + + if runtime.GOOS != "windows" { + path, err := exec.Command("which", executableName).Output() + if err == nil { + trimmed := strings.TrimSpace(string(path)) + if trimmed != "" { + return trimmed + } + } + } + + path, err := exec.LookPath(executableName) + if err == nil { + return path + } + + return "" +} + func GetFFmpegPath() (string, error) { ffmpegDir, err := GetFFmpegDir() if err != nil { @@ -94,38 +125,15 @@ func GetFFmpegPath() (string, error) { ffmpegName = "ffmpeg.exe" } + if path := resolveSystemExecutable(ffmpegName); path != "" { + return path, nil + } + localPath := filepath.Join(ffmpegDir, ffmpegName) if _, err := os.Stat(localPath); err == nil { return localPath, nil } - if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { - homebrewPath := "/opt/homebrew/bin/" + ffmpegName - if _, err := os.Stat(homebrewPath); err == nil { - return homebrewPath, nil - } - } else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" { - homebrewPath := "/usr/local/bin/" + ffmpegName - if _, err := os.Stat(homebrewPath); err == nil { - return homebrewPath, nil - } - } - - if runtime.GOOS != "windows" { - path, err := exec.Command("which", ffmpegName).Output() - if err == nil { - trimmed := strings.TrimSpace(string(path)) - if trimmed != "" { - return trimmed, nil - } - } - } - - path, err := exec.LookPath(ffmpegName) - if err == nil { - return path, nil - } - return localPath, nil } @@ -140,38 +148,15 @@ func GetFFprobePath() (string, error) { ffprobeName = "ffprobe.exe" } + if path := resolveSystemExecutable(ffprobeName); path != "" { + return path, nil + } + localPath := filepath.Join(ffmpegDir, ffprobeName) if _, err := os.Stat(localPath); err == nil { return localPath, nil } - if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { - homebrewPath := "/opt/homebrew/bin/" + ffprobeName - if _, err := os.Stat(homebrewPath); err == nil { - return homebrewPath, nil - } - } else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" { - homebrewPath := "/usr/local/bin/" + ffprobeName - if _, err := os.Stat(homebrewPath); err == nil { - return homebrewPath, nil - } - } - - if runtime.GOOS != "windows" { - path, err := exec.Command("which", ffprobeName).Output() - if err == nil { - trimmed := strings.TrimSpace(string(path)) - if trimmed != "" { - return trimmed, nil - } - } - } - - path, err := exec.LookPath(ffprobeName) - if err == nil { - return path, nil - } - return localPath, fmt.Errorf("ffprobe not found in app directory or system path") } @@ -205,7 +190,11 @@ func IsFFmpegInstalled() (bool, error) { setHideWindow(cmd) err = cmd.Run() - return err == nil, nil + if err != nil { + return false, nil + } + + return IsFFprobeInstalled() } func GetBrewPath() string { @@ -255,10 +244,38 @@ func InstallFFmpegWithBrew(progressCallback func(int, string)) error { return nil } -const ( - ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip" - ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz" -) +const ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1" + +func buildFFmpegReleaseURL(assetName string) string { + return ffmpegReleaseBaseURL + "/" + assetName +} + +func getFFmpegDownloadURLs() ([]string, []string, error) { + switch runtime.GOOS { + case "windows": + return []string{buildFFmpegReleaseURL("ffmpeg-windows.zip")}, []string{buildFFmpegReleaseURL("ffprobe-windows.zip")}, nil + case "linux": + switch runtime.GOARCH { + case "amd64": + return []string{buildFFmpegReleaseURL("ffmpeg-linux-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-amd64.zip")}, nil + case "arm64": + return []string{buildFFmpegReleaseURL("ffmpeg-linux-arm64v8.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-arm64v8.zip")}, nil + default: + return nil, nil, fmt.Errorf("unsupported Linux architecture: %s", runtime.GOARCH) + } + case "darwin": + switch runtime.GOARCH { + case "amd64": + return []string{buildFFmpegReleaseURL("ffmpeg-macos-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-amd64.zip")}, nil + case "arm64": + return []string{buildFFmpegReleaseURL("ffmpeg-macos-arm64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-arm64.zip")}, nil + default: + return nil, nil, fmt.Errorf("unsupported macOS architecture: %s", runtime.GOARCH) + } + default: + return nil, nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } +} func DownloadFFmpeg(progressCallback func(int)) error { @@ -276,57 +293,30 @@ func DownloadFFmpeg(progressCallback func(int)) error { return fmt.Errorf("failed to create ffmpeg directory: %w", err) } - if runtime.GOOS == "darwin" { - ffmpegInstalled, _ := IsFFmpegInstalled() - ffprobeInstalled, _ := IsFFprobeInstalled() + ffmpegInstalled, _ := IsFFmpegInstalled() + ffprobeInstalled, _ := IsFFprobeInstalled() - isARM := runtime.GOARCH == "arm64" + ffmpegURLs, ffprobeURLs, err := getFFmpegDownloadURLs() + if err != nil { + return err + } - var macFFmpegURLs []string - var macFFprobeURLs []string - - if isARM { - - macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"} - macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"} - } else { - - macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"} - macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"} + if !ffmpegInstalled && !ffprobeInstalled { + if err := downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil { + return err } - - if !ffmpegInstalled && !ffprobeInstalled { - if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil { - return err - } - if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil { - return err - } - } else if !ffmpegInstalled { - if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil { - return err - } - } else if !ffprobeInstalled { - if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil { - return err - } + if err := downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil { + return err } return nil } - var url string - switch runtime.GOOS { - case "windows": - url = ffmpegWindowsURL - case "linux": - url = ffmpegLinuxURL - default: - return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + if !ffmpegInstalled { + return downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 100) } - fmt.Printf("[FFmpeg] Downloading from: %s\n", url) - if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil { - return err + if !ffprobeInstalled { + return downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 0, 100) } return nil @@ -452,10 +442,13 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres } fmt.Printf("[FFmpeg] Extracting...\n") - if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" { + if strings.HasSuffix(url, ".tar.xz") { return extractTarXz(tmpFile.Name(), destDir) } - return extractZip(tmpFile.Name(), destDir) + if strings.HasSuffix(url, ".zip") { + return extractZip(tmpFile.Name(), destDir) + } + return fmt.Errorf("unsupported archive format for %s", url) } func extractZip(zipPath, destDir string) error { diff --git a/backend/filemanager.go b/backend/filemanager.go index 9b915fb..12f3b33 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -30,6 +30,8 @@ type AudioMetadata struct { TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` Year string `json:"year"` + ISRC string `json:"isrc"` + UPC string `json:"upc"` } type RenamePreview struct { @@ -175,6 +177,12 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) { } case "DATE", "YEAR": metadata.Year = value + case "ISRC", "TSRC": + metadata.ISRC = value + case "UPC": + assignPreferredUPC(&metadata.UPC, value, true) + case "BARCODE": + assignPreferredUPC(&metadata.UPC, value, false) } } } @@ -221,6 +229,28 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { } } + if frames := tag.GetFrames("TSRC"); len(frames) > 0 { + if textFrame, ok := frames[0].(id3v2.TextFrame); ok { + metadata.ISRC = textFrame.Text + } + } + if frames := tag.GetFrames("TXXX"); len(frames) > 0 { + for _, frame := range frames { + userTextFrame, ok := frame.(id3v2.UserDefinedTextFrame) + if !ok { + continue + } + matched, preferred := classifyUPCDescription(userTextFrame.Description) + if !matched { + continue + } + assignPreferredUPC(&metadata.UPC, userTextFrame.Value, preferred) + if preferred && strings.TrimSpace(metadata.UPC) != "" { + break + } + } + } + return metadata, nil } @@ -301,9 +331,13 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { if metadata.Year == "" || len(value) > len(metadata.Year) { metadata.Year = value } + case "isrc", "tsrc": + metadata.ISRC = value } } + metadata.UPC = firstPreferredFFprobeUPCValue(allTags) + return metadata, nil } @@ -333,6 +367,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year)) result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year)) + result = strings.ReplaceAll(result, "{isrc}", sanitizeFilenameForRename(metadata.ISRC)) if metadata.TrackNumber > 0 { result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) diff --git a/backend/filename.go b/backend/filename.go index 0c8a373..91ae94d 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -2,6 +2,7 @@ package backend import ( "fmt" + "os" "path/filepath" "regexp" "strings" @@ -9,12 +10,12 @@ import ( "unicode/utf8" ) -func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { - +func buildFormattedFilenameBase(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner, isrc string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { safeTitle := SanitizeFilename(trackName) safeArtist := SanitizeFilename(artistName) safeAlbum := SanitizeFilename(albumName) safeAlbumArtist := SanitizeFilename(albumArtist) + safeISRC := SanitizeOptionalFilename(isrc) safePlaylist := SanitizeFilename(playlistName) safeCreator := SanitizeFilename(playlistOwner) @@ -36,6 +37,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist) filename = strings.ReplaceAll(filename, "{creator}", safeCreator) + filename = strings.ReplaceAll(filename, "{isrc}", safeISRC) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) @@ -67,7 +69,47 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas } } - return filename + ".flac" + return filename +} + +func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool, extra ...string) string { + isrc := "" + if len(extra) > 0 { + isrc = extra[0] + } + + return buildFormattedFilenameBase(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner, isrc, includeTrackNumber, position, discNumber, useAlbumTrackNumber) + ".flac" +} + +func ResolveOutputPathForDownload(path string, redownloadWithSuffix bool) (string, bool) { + if !redownloadWithSuffix { + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + return path, true + } + return path, false + } + + if info, err := os.Stat(path); err != nil || info.Size() == 0 { + return path, false + } + + ext := filepath.Ext(path) + base := strings.TrimSuffix(path, ext) + + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s_%02d%s", base, i, ext) + if info, err := os.Stat(candidate); err != nil || info.Size() == 0 { + return candidate, false + } + } +} + +func mustFileSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() } func SanitizeFilename(name string) string { @@ -188,3 +230,10 @@ func sanitizeFolderName(name string) string { return SanitizeFilename(name) } func sanitizeFilename(name string) string { return SanitizeFilename(name) } + +func SanitizeOptionalFilename(name string) string { + if strings.TrimSpace(name) == "" { + return "" + } + return SanitizeFilename(name) +} diff --git a/backend/isrc_finder.go b/backend/isrc_finder.go index cf077ea..4796c37 100644 --- a/backend/isrc_finder.go +++ b/backend/isrc_finder.go @@ -1,9 +1,6 @@ package backend import ( - "crypto/hmac" - "crypto/sha1" - "encoding/binary" "encoding/json" "errors" "fmt" @@ -20,16 +17,10 @@ import ( ) const ( - spotifyServerTimeURL = "https://open.spotify.com/api/server-time" - spotifySessionTokenURL = "https://open.spotify.com/api/token" - spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json" - spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token" - spotifyTOTPPeriod = 30 - spotifyTOTPDigits = 6 - spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - spotifyTokenCacheFile = ".isrc-finder-token.json" - spotifySecretsCacheFile = "spotify-secret-dict-cache.json" - spotifySecretsCacheTTL = 24 * time.Hour + spotifySessionTokenURL = "https://open.spotify.com/api/token" + spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token" + spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + spotifyTokenCacheFile = ".isrc-finder-token.json" ) var spotifyAnonymousTokenMu sync.Mutex @@ -39,91 +30,104 @@ type spotifyAnonymousToken struct { AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"` } -type spotifyServerTimeResponse struct { - ServerTime int64 `json:"serverTime"` -} - -type spotifySecretsCache struct { - FetchedAtUnix int64 `json:"fetched_at_unix"` - Secrets map[string][]int `json:"secrets"` -} - type spotifyTrackRawData struct { + Album struct { + GID string `json:"gid"` + } `json:"album"` ExternalID []struct { Type string `json:"type"` ID string `json:"id"` } `json:"external_id"` } -type spotFetchISRCResponse struct { - Input string `json:"input"` - TrackID string `json:"track_id"` - GID string `json:"gid"` - CanonicalURI string `json:"canonical_uri"` - Name string `json:"name"` - Artists []string `json:"artists"` - AlbumName string `json:"album_name"` - ReleaseDate string `json:"release_date"` - Label string `json:"label"` - ISRC string `json:"isrc"` +type spotifyAlbumRawData struct { + ExternalID []struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"external_id"` } -func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { +type SpotifyTrackIdentifiers struct { + ISRC string `json:"isrc,omitempty"` + UPC string `json:"upc,omitempty"` +} + +func GetSpotifyTrackIdentifiersDirect(spotifyTrackID string) (SpotifyTrackIdentifiers, error) { normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) if err != nil { - return "", err + return SpotifyTrackIdentifiers{}, err } + identifiers := SpotifyTrackIdentifiers{} + cachedISRC, err := GetCachedISRC(normalizedTrackID) if err != nil { fmt.Printf("Warning: failed to read ISRC cache: %v\n", err) } else if cachedISRC != "" { fmt.Printf("Found ISRC in cache: %s\n", cachedISRC) - return cachedISRC, nil + identifiers.ISRC = cachedISRC } - useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings() - if useSpotFetchAPI { - isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL) - if err == nil && isrc != "" { - fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) - return isrc, nil - } - if err != nil { - fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err) - } - } + httpClient := &http.Client{Timeout: 30 * time.Second} - payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID) + payload, metadataErr := fetchSpotifyTrackRawData(httpClient, normalizedTrackID) if metadataErr == nil { - isrc, extractErr := extractSpotifyTrackISRC(payload) + metadataIdentifiers, extractErr := extractSpotifyTrackIdentifiers(httpClient, payload) if extractErr == nil { - fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc) - return isrc, nil + mergeSpotifyTrackIdentifiers(&identifiers, metadataIdentifiers) + if identifiers.ISRC != "" { + fmt.Printf("Found identifiers via Spotify metadata: isrc=%s upc=%s\n", identifiers.ISRC, identifiers.UPC) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", identifiers.ISRC) + } + if identifiers.ISRC != "" && identifiers.UPC != "" { + return identifiers, nil + } } metadataErr = extractErr } if metadataErr != nil { - fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr) + fmt.Printf("Warning: Spotify metadata identifier lookup failed, falling back to Soundplate: %v\n", metadataErr) } - isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID) - if soundplateErr == nil && isrc != "" { - fmt.Printf("Found ISRC via Soundplate: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) - return isrc, nil + if identifiers.ISRC == "" { + client := NewSongLinkClient() + isrc, resolvedTrackID, soundplateErr := client.lookupSpotifyISRCViaSoundplate(normalizedTrackID) + if soundplateErr == nil && isrc != "" { + identifiers.ISRC = isrc + fmt.Printf("Found ISRC via Soundplate: %s\n", isrc) + cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) + return identifiers, nil + } + + if metadataErr != nil && soundplateErr != nil { + return identifiers, fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr) + } + if soundplateErr != nil && identifiers.UPC == "" { + return identifiers, soundplateErr + } } - if metadataErr != nil && soundplateErr != nil { - return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr) + if identifiers.ISRC != "" || identifiers.UPC != "" { + return identifiers, nil } - if soundplateErr != nil { - return "", soundplateErr + if metadataErr != nil { + return identifiers, metadataErr } - return "", metadataErr + + return identifiers, fmt.Errorf("no Spotify identifiers found for track %s", normalizedTrackID) +} + +func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { + identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyTrackID) + if err != nil { + return "", err + } + if identifiers.ISRC == "" { + return "", fmt.Errorf("no Spotify ISRC found for track %s", strings.TrimSpace(spotifyTrackID)) + } + + return identifiers.ISRC, nil } func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) { @@ -137,47 +141,28 @@ func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc } } -func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) { - normalizedTrackID := strings.TrimSpace(spotifyTrackID) - baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/") - if normalizedTrackID == "" { - return "", "", fmt.Errorf("spotify track ID is required") +func mergeSpotifyTrackIdentifiers(target *SpotifyTrackIdentifiers, incoming SpotifyTrackIdentifiers) { + if incoming.ISRC != "" { + target.ISRC = strings.TrimSpace(incoming.ISRC) } - if baseURL == "" { - return "", "", fmt.Errorf("spotfetch api url is required") + if incoming.UPC != "" { + target.UPC = strings.TrimSpace(incoming.UPC) + } +} + +func lookupSpotifyAlbumUPC(albumID string) (string, error) { + normalizedAlbumID := strings.TrimSpace(albumID) + if normalizedAlbumID == "" { + return "", fmt.Errorf("spotify album ID is required") } - requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID)) - req, err := http.NewRequest(http.MethodGet, requestURL, nil) + httpClient := &http.Client{Timeout: 30 * time.Second} + payload, err := fetchSpotifyAlbumRawData(httpClient, normalizedAlbumID) if err != nil { - return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - req.Header.Set("Accept", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) - return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview))) + return "", err } - var payload spotFetchISRCResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err) - } - - isrc := firstISRCMatch(payload.ISRC) - if isrc == "" { - return "", "", fmt.Errorf("ISRC missing in SpotFetch response") - } - - return isrc, strings.TrimSpace(payload.TrackID), nil + return extractSpotifyAlbumUPC(payload) } func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) { @@ -269,50 +254,6 @@ func saveSpotifyCachedToken(token *spotifyAnonymousToken) error { return nil } -func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) { - cachePath, err := spotifySecretsCachePath() - if err != nil { - return nil, err - } - - body, err := os.ReadFile(cachePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, fmt.Errorf("failed to read secrets cache: %w", err) - } - - var cache spotifySecretsCache - if err := json.Unmarshal(body, &cache); err != nil { - return nil, fmt.Errorf("failed to parse secrets cache: %w", err) - } - - return &cache, nil -} - -func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error { - cachePath, err := spotifySecretsCachePath() - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { - return fmt.Errorf("failed to create secrets cache directory: %w", err) - } - - body, err := json.MarshalIndent(cache, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(cachePath, body, 0o644); err != nil { - return fmt.Errorf("failed to write secrets cache: %w", err) - } - - return nil -} - func spotifyTokenCachePath() (string, error) { appDir, err := EnsureAppDir() if err != nil { @@ -322,15 +263,6 @@ func spotifyTokenCachePath() (string, error) { return filepath.Join(appDir, spotifyTokenCacheFile), nil } -func spotifySecretsCachePath() (string, error) { - appDir, err := EnsureAppDir() - if err != nil { - return "", err - } - - return filepath.Join(appDir, spotifySecretsCacheFile), nil -} - func spotifyTokenIsValid(token *spotifyAnonymousToken) bool { if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 { return false @@ -339,47 +271,6 @@ func spotifyTokenIsValid(token *spotifyAnonymousToken) bool { return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000 } -func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool { - if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 { - return false - } - - return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL -} - -func deriveSpotifyTOTPSecret(ciphertext []int) []byte { - var builder strings.Builder - - for index, value := range ciphertext { - builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9))) - } - - return []byte(builder.String()) -} - -func generateSpotifyTOTP(secret []byte, timestampMs int64) string { - counter := timestampMs / 1000 / spotifyTOTPPeriod - counterBytes := make([]byte, 8) - binary.BigEndian.PutUint64(counterBytes, uint64(counter)) - - mac := hmac.New(sha1.New, secret) - mac.Write(counterBytes) - digest := mac.Sum(nil) - - offset := digest[len(digest)-1] & 0x0f - binaryCode := (int(digest[offset])&0x7f)<<24 | - (int(digest[offset+1])&0xff)<<16 | - (int(digest[offset+2])&0xff)<<8 | - (int(digest[offset+3]) & 0xff) - - modulo := 1 - for i := 0; i < spotifyTOTPDigits; i++ { - modulo *= 10 - } - - return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo) -} - func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) { spotifyAnonymousTokenMu.Lock() defer spotifyAnonymousTokenMu.Unlock() @@ -393,52 +284,17 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) { return cachedToken.AccessToken, nil } - var serverTime spotifyServerTimeResponse - if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil { - return "", err - } - - var secrets map[string][]int - cachedSecrets, err := loadSpotifyCachedSecrets() + generatedTOTP, version, err := generateSpotifyTOTP(time.Now()) if err != nil { - fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err) + return "", fmt.Errorf("failed to generate Spotify TOTP: %w", err) } - if spotifySecretsCacheIsValid(cachedSecrets) { - secrets = cachedSecrets.Secrets - } else { - if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil { - if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 { - fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err) - secrets = cachedSecrets.Secrets - } else { - return "", err - } - } else { - cache := &spotifySecretsCache{ - FetchedAtUnix: time.Now().Unix(), - Secrets: secrets, - } - if err := saveSpotifyCachedSecrets(cache); err != nil { - fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err) - } - } - } - - version, err := latestSpotifySecretVersion(secrets) - if err != nil { - return "", err - } - - secret := deriveSpotifyTOTPSecret(secrets[version]) - generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000) - query := url.Values{ "reason": {"init"}, "productType": {"web-player"}, "totp": {generatedTOTP}, "totpServer": {generatedTOTP}, - "totpVer": {version}, + "totpVer": {strconv.Itoa(version)}, } var token spotifyAnonymousToken @@ -453,30 +309,6 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) { return token.AccessToken, nil } -func latestSpotifySecretVersion(secrets map[string][]int) (string, error) { - var ( - bestVersion string - bestNumber int - ) - - for version := range secrets { - number, err := strconv.Atoi(version) - if err != nil { - return "", fmt.Errorf("invalid secret version %q: %w", version, err) - } - if bestVersion == "" || number > bestNumber { - bestVersion = version - bestNumber = number - } - } - - if bestVersion == "" { - return "", errors.New("no TOTP secret versions available") - } - - return bestVersion, nil -} - func extractSpotifyTrackID(value string) (string, error) { value = strings.TrimSpace(value) if value == "" { @@ -504,14 +336,18 @@ func extractSpotifyTrackID(value string) (string, error) { } func spotifyTrackIDToGID(trackID string) (string, error) { - if trackID == "" { - return "", errors.New("track ID is empty") + return spotifyEntityIDToGID(trackID) +} + +func spotifyEntityIDToGID(entityID string) (string, error) { + if entityID == "" { + return "", errors.New("entity ID is empty") } value := big.NewInt(0) base := big.NewInt(62) - for _, char := range trackID { + for _, char := range entityID { index := strings.IndexRune(spotifyBase62Alphabet, char) if index < 0 { return "", fmt.Errorf("invalid base62 character: %q", string(char)) @@ -530,43 +366,99 @@ func spotifyTrackIDToGID(trackID string) (string, error) { } func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) { - accessToken, err := requestSpotifyAnonymousAccessToken(client) + gid, err := spotifyTrackIDToGID(trackID) if err != nil { return nil, err } - gid, err := spotifyTrackIDToGID(trackID) + return fetchSpotifyRawMetadataByGID(client, "track", gid) +} + +func fetchSpotifyAlbumRawData(client *http.Client, albumID string) ([]byte, error) { + gid, err := spotifyEntityIDToGID(albumID) + if err != nil { + return nil, err + } + + return fetchSpotifyRawMetadataByGID(client, "album", gid) +} + +func fetchSpotifyRawMetadataByGID(client *http.Client, entityType string, gid string) ([]byte, error) { + accessToken, err := requestSpotifyAnonymousAccessToken(client) if err != nil { return nil, err } return requestSpotifyBytes( client, - fmt.Sprintf(spotifyGIDMetadataURL, "track", gid), + fmt.Sprintf(spotifyGIDMetadataURL, entityType, gid), map[string]string{ "authorization": "Bearer " + accessToken, "accept": "application/json", + "user-agent": songLinkUserAgent, }, ) } -func extractSpotifyTrackISRC(payload []byte) (string, error) { +func extractSpotifyTrackIdentifiers(client *http.Client, payload []byte) (SpotifyTrackIdentifiers, error) { var track spotifyTrackRawData if err := json.Unmarshal(payload, &track); err != nil { - return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err) + return SpotifyTrackIdentifiers{}, fmt.Errorf("failed to decode Spotify track metadata: %w", err) } + identifiers := SpotifyTrackIdentifiers{} for _, externalID := range track.ExternalID { if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") { if isrc := firstISRCMatch(externalID.ID); isrc != "" { - return isrc, nil + identifiers.ISRC = isrc + break } } } - if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" { - return fallbackISRC, nil + if identifiers.ISRC == "" { + identifiers.ISRC = firstISRCMatch(string(payload)) + } + + albumGID := strings.TrimSpace(track.Album.GID) + if client != nil && albumGID != "" { + albumPayload, err := fetchSpotifyRawMetadataByGID(client, "album", albumGID) + if err == nil { + if upc, upcErr := extractSpotifyAlbumUPC(albumPayload); upcErr == nil { + identifiers.UPC = upc + } + } + } + + return identifiers, nil +} + +func extractSpotifyTrackISRC(payload []byte) (string, error) { + identifiers, err := extractSpotifyTrackIdentifiers(nil, payload) + if err != nil { + return "", err + } + if identifiers.ISRC != "" { + return identifiers.ISRC, nil } return "", fmt.Errorf("ISRC not found in Spotify track metadata") } + +func extractSpotifyAlbumUPC(payload []byte) (string, error) { + var album spotifyAlbumRawData + if err := json.Unmarshal(payload, &album); err != nil { + return "", fmt.Errorf("failed to decode Spotify album metadata: %w", err) + } + + for _, externalID := range album.ExternalID { + if strings.EqualFold(strings.TrimSpace(externalID.Type), "upc") { + upc := strings.TrimSpace(externalID.ID) + if upc != "" { + return upc, nil + } + } + } + + return "", fmt.Errorf("UPC not found in Spotify album metadata") +} diff --git a/backend/isrc_helper.go b/backend/isrc_helper.go new file mode 100644 index 0000000..2f8283b --- /dev/null +++ b/backend/isrc_helper.go @@ -0,0 +1,22 @@ +package backend + +import "strings" + +func ResolveTrackISRC(spotifyTrackID string) string { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + if spotifyTrackID == "" { + return "" + } + + if cachedISRC, err := GetCachedISRC(spotifyTrackID); err == nil && cachedISRC != "" { + return strings.ToUpper(strings.TrimSpace(cachedISRC)) + } + + client := NewSongLinkClient() + isrc, err := client.GetISRCDirect(spotifyTrackID) + if err != nil { + return "" + } + + return strings.ToUpper(strings.TrimSpace(isrc)) +} diff --git a/backend/lyrics.go b/backend/lyrics.go index 3feba0d..16e025a 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -44,6 +44,7 @@ type LyricsDownloadRequest struct { AlbumName string `json:"album_name"` AlbumArtist string `json:"album_artist"` ReleaseDate string `json:"release_date"` + ISRC string `json:"isrc"` OutputDir string `json:"output_dir"` FilenameFormat string `json:"filename_format"` TrackNumber bool `json:"track_number"` @@ -363,11 +364,12 @@ func msToLRCTimestamp(msStr string) string { return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) } -func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string { +func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, isrc string, includeTrackNumber bool, position, discNumber int) string { safeTitle := sanitizeFilename(trackName) safeArtist := sanitizeFilename(artistName) safeAlbum := sanitizeFilename(albumName) safeAlbumArtist := sanitizeFilename(albumArtist) + safeISRC := SanitizeOptionalFilename(isrc) year := "" if len(releaseDate) >= 4 { @@ -384,6 +386,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate)) + filename = strings.ReplaceAll(filename, "{isrc}", safeISRC) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) @@ -485,10 +488,15 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa if filenameFormat == "" { filenameFormat = "title-artist" } - filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber) + resolvedISRC := strings.TrimSpace(req.ISRC) + if resolvedISRC == "" && strings.Contains(filenameFormat, "{isrc}") { + resolvedISRC = ResolveTrackISRC(req.SpotifyID) + } + filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, resolvedISRC, req.TrackNumber, req.Position, req.DiscNumber) filePath := filepath.Join(outputDir, filename) - if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { + filePath, alreadyExists := ResolveOutputPathForDownload(filePath, GetRedownloadWithSuffixSetting()) + if alreadyExists { return &LyricsDownloadResponse{ Success: true, Message: "Lyrics file already exists", diff --git a/backend/metadata.go b/backend/metadata.go index 50cd52d..f438338 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -21,6 +21,7 @@ type Metadata struct { Artist string Album string AlbumArtist string + Separator string Date string ReleaseDate string TrackNumber int @@ -31,12 +32,73 @@ type Metadata struct { Comment string Copyright string Publisher string + Composer string Lyrics string Description string ISRC string + UPC string Genre string } +func resolveMetadataSeparator(separator string) string { + if normalized := normalizeArtistSeparator(separator); normalized != "" { + return normalized + } + + return normalizeArtistSeparator(GetSeparator()) +} + +func displayMetadataSeparator(separator string) string { + if resolved := resolveMetadataSeparator(separator); resolved != "" { + return resolved + " " + } + + return "; " +} + +func addVorbisTagValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string, values []string) { + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + + _ = cmt.Add(key, value) + } +} + +func addMP3TextFrame(tag *id3v2.Tag, frameID string, value string) { + tag.DeleteFrames(frameID) + value = strings.TrimSpace(value) + if value == "" { + return + } + + tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value) +} + +func joinMultiValueText(values []string, separator string, nullSeparated bool) string { + cleaned := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + cleaned = append(cleaned, value) + } + } + + if len(cleaned) == 0 { + return "" + } + if len(cleaned) == 1 { + return cleaned[0] + } + if nullSeparated { + return strings.Join(cleaned, "\x00") + } + + return strings.Join(cleaned, displayMetadataSeparator(separator)) +} + func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { f, err := flac.ParseFile(filepath) if err != nil { @@ -52,17 +114,22 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { } cmt := flacvorbis.New() + separator := resolveMetadataSeparator(metadata.Separator) if metadata.Title != "" { _ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title) } - if metadata.Artist != "" { + if artistValues := SplitArtistCredits(metadata.Artist, separator); len(artistValues) > 0 { + addVorbisTagValues(cmt, flacvorbis.FIELD_ARTIST, artistValues) + } else if metadata.Artist != "" { _ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist) } if metadata.Album != "" { _ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album) } - if metadata.AlbumArtist != "" { + if albumArtistValues := SplitArtistCredits(metadata.AlbumArtist, separator); len(albumArtistValues) > 0 { + addVorbisTagValues(cmt, "ALBUMARTIST", albumArtistValues) + } else if metadata.AlbumArtist != "" { _ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist) } if metadata.Date != "" { @@ -86,6 +153,11 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.Publisher != "" { _ = cmt.Add("PUBLISHER", metadata.Publisher) } + if composerValues := SplitArtistCredits(metadata.Composer, separator); len(composerValues) > 0 { + addVorbisTagValues(cmt, "COMPOSER", composerValues) + } else if metadata.Composer != "" { + _ = cmt.Add("COMPOSER", metadata.Composer) + } if metadata.Description != "" { _ = cmt.Add("DESCRIPTION", metadata.Description) } @@ -96,8 +168,13 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.ISRC != "" { _ = cmt.Add("ISRC", metadata.ISRC) } + if metadata.UPC != "" { + _ = cmt.Add(preferredUPCTagKey, metadata.UPC) + } - if metadata.Genre != "" { + if genreValues := SplitMetadataValues(metadata.Genre, separator); len(genreValues) > 0 { + addVorbisTagValues(cmt, "GENRE", genreValues) + } else if metadata.Genre != "" { _ = cmt.Add("GENRE", metadata.Genre) } @@ -901,8 +978,14 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { metadata.Copyright = value case "publisher", "tpub", "label": metadata.Publisher = value + case "composer", "writer", "wm/composer", "©wrt": + metadata.Composer = value + case "genre", "tcon": + metadata.Genre = value case "url": metadata.URL = value + case "isrc", "tsrc": + metadata.ISRC = value case "comment", "comments": if metadata.Comment == "" { metadata.Comment = value @@ -914,6 +997,8 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { } } + metadata.UPC = firstPreferredFFprobeUPCValue(allTags) + return metadata, nil } @@ -940,15 +1025,13 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er return fmt.Errorf("failed to open MP3 file: %w", err) } defer tag.Close() + separator := resolveMetadataSeparator(metadata.Separator) tag.DeleteFrames("TXXX") if metadata.Title != "" { tag.SetTitle(metadata.Title) } - if metadata.Artist != "" { - tag.SetArtist(metadata.Artist) - } if metadata.Album != "" { tag.SetAlbum(metadata.Album) } @@ -960,10 +1043,17 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er tag.SetYear(year) } - if metadata.AlbumArtist != "" { - tag.DeleteFrames("TPE2") - tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist) + artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, true) + if artistText == "" { + artistText = strings.TrimSpace(metadata.Artist) } + addMP3TextFrame(tag, "TPE1", artistText) + + albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, true) + if albumArtistText == "" { + albumArtistText = strings.TrimSpace(metadata.AlbumArtist) + } + addMP3TextFrame(tag, "TPE2", albumArtistText) if metadata.TrackNumber > 0 { tag.DeleteFrames(tag.CommonID("Track number/Position in set")) @@ -984,18 +1074,28 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er } if metadata.Copyright != "" { - tag.DeleteFrames("TCOP") - tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright) + addMP3TextFrame(tag, "TCOP", metadata.Copyright) } if metadata.Publisher != "" { - tag.DeleteFrames("TPUB") - tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher) + addMP3TextFrame(tag, "TPUB", metadata.Publisher) } + composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, true) + if composerText == "" { + composerText = strings.TrimSpace(metadata.Composer) + } + addMP3TextFrame(tag, "TCOM", composerText) + if metadata.ISRC != "" { - tag.DeleteFrames("TSRC") - tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC) + addMP3TextFrame(tag, "TSRC", metadata.ISRC) + } + if metadata.UPC != "" { + tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ + Encoding: id3v2.EncodingUTF8, + Description: "UPC", + Value: metadata.UPC, + }) } if comment := resolveMetadataComment(metadata); comment != "" { @@ -1027,6 +1127,12 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er } } + genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, true) + if genreText == "" { + genreText = strings.TrimSpace(metadata.Genre) + } + addMP3TextFrame(tag, "TCON", genreText) + if err := tag.Save(); err != nil { return fmt.Errorf("failed to save MP3 tags: %w", err) } @@ -1048,6 +1154,7 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er "-i", filePath, "-y", } + separator := resolveMetadataSeparator(metadata.Separator) if coverPath != "" && fileExists(coverPath) { args = append(args, "-i", coverPath) @@ -1059,14 +1166,22 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.Title != "" { args = append(args, "-metadata", "title="+metadata.Title) } - if metadata.Artist != "" { - args = append(args, "-metadata", "artist="+metadata.Artist) + artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, false) + if artistText == "" { + artistText = strings.TrimSpace(metadata.Artist) + } + if artistText != "" { + args = append(args, "-metadata", "artist="+artistText) } if metadata.Album != "" { args = append(args, "-metadata", "album="+metadata.Album) } - if metadata.AlbumArtist != "" { - args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist) + albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, false) + if albumArtistText == "" { + albumArtistText = strings.TrimSpace(metadata.AlbumArtist) + } + if albumArtistText != "" { + args = append(args, "-metadata", "album_artist="+albumArtistText) } if metadata.Date != "" { args = append(args, "-metadata", "date="+metadata.Date) @@ -1091,9 +1206,26 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.Publisher != "" { args = append(args, "-metadata", "publisher="+metadata.Publisher) } + composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, false) + if composerText == "" { + composerText = strings.TrimSpace(metadata.Composer) + } + if composerText != "" { + args = append(args, "-metadata", "composer="+composerText) + } if metadata.ISRC != "" { args = append(args, "-metadata", "isrc="+metadata.ISRC) } + if metadata.UPC != "" { + args = append(args, "-metadata", "upc="+metadata.UPC) + } + genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, false) + if genreText == "" { + genreText = strings.TrimSpace(metadata.Genre) + } + if genreText != "" { + args = append(args, "-metadata", "genre="+genreText) + } if comment := resolveMetadataComment(metadata); comment != "" { args = append(args, "-metadata", "comment="+comment) } diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go index 2cca4af..81cb2f9 100644 --- a/backend/musicbrainz.go +++ b/backend/musicbrainz.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "golang.org/x/text/cases" @@ -14,7 +15,66 @@ import ( var AppVersion = "Unknown" -const musicBrainzAPIBase = "https://musicbrainz.org/ws/2" +const ( + musicBrainzAPIBase = "https://musicbrainz.org/ws/2" + musicBrainzRequestTimeout = 10 * time.Second + musicBrainzRequestRetries = 3 + musicBrainzRequestRetryWait = 3 * time.Second + musicBrainzMinRequestInterval = 1100 * time.Millisecond + musicBrainzThrottleCooldownOn503 = 5 * time.Second + musicBrainzStatusCheckSkipWindow = 5 * time.Minute +) + +type musicBrainzStatusError struct { + StatusCode int +} + +func (e *musicBrainzStatusError) Error() string { + return fmt.Sprintf("MusicBrainz API returned status: %d", e.StatusCode) +} + +type musicBrainzInflightCall struct { + done chan struct{} + result Metadata + err error +} + +var ( + musicBrainzCache sync.Map + musicBrainzInflightMu sync.Mutex + musicBrainzInflight = make(map[string]*musicBrainzInflightCall) + + musicBrainzThrottleMu sync.Mutex + musicBrainzNextRequest time.Time + musicBrainzBlockedTill time.Time + + musicBrainzStatusMu sync.RWMutex + musicBrainzLastCheckedAt time.Time + musicBrainzLastCheckedOnline bool +) + +func SetMusicBrainzStatusCheckResult(online bool) { + musicBrainzStatusMu.Lock() + defer musicBrainzStatusMu.Unlock() + + musicBrainzLastCheckedAt = time.Now() + musicBrainzLastCheckedOnline = online +} + +func ShouldSkipMusicBrainzMetadataFetch() bool { + musicBrainzStatusMu.RLock() + defer musicBrainzStatusMu.RUnlock() + + if musicBrainzLastCheckedAt.IsZero() { + return false + } + + if musicBrainzLastCheckedOnline { + return false + } + + return time.Since(musicBrainzLastCheckedAt) <= musicBrainzStatusCheckSkipWindow +} type MusicBrainzRecordingResponse struct { Recordings []struct { @@ -54,66 +114,176 @@ type MusicBrainzRecordingResponse struct { } `json:"recordings"` } +func musicBrainzCacheKey(isrc string, useSingleGenre bool) string { + separator := strings.TrimSpace(GetSeparator()) + if separator == "" { + separator = ";" + } + + return strings.ToUpper(strings.TrimSpace(isrc)) + "|" + fmt.Sprintf("%t", useSingleGenre) + "|" + separator +} + +func waitForMusicBrainzRequestSlot() { + musicBrainzThrottleMu.Lock() + + readyAt := musicBrainzNextRequest + if musicBrainzBlockedTill.After(readyAt) { + readyAt = musicBrainzBlockedTill + } + + now := time.Now() + if readyAt.Before(now) { + readyAt = now + } + + musicBrainzNextRequest = readyAt.Add(musicBrainzMinRequestInterval) + waitDuration := time.Until(readyAt) + + musicBrainzThrottleMu.Unlock() + + if waitDuration > 0 { + time.Sleep(waitDuration) + } +} + +func noteMusicBrainzThrottle() { + musicBrainzThrottleMu.Lock() + defer musicBrainzThrottleMu.Unlock() + + cooldownUntil := time.Now().Add(musicBrainzThrottleCooldownOn503) + if cooldownUntil.After(musicBrainzBlockedTill) { + musicBrainzBlockedTill = cooldownUntil + } + if musicBrainzNextRequest.Before(musicBrainzBlockedTill) { + musicBrainzNextRequest = musicBrainzBlockedTill + } +} + +func shouldRetryMusicBrainzRequest(err error) bool { + if err == nil { + return false + } + + statusErr, ok := err.(*musicBrainzStatusError) + if !ok { + return true + } + + return statusErr.StatusCode == http.StatusServiceUnavailable || statusErr.StatusCode >= http.StatusInternalServerError +} + +func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainzRecordingResponse, error) { + reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query)) + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion)) + req.Header.Set("Accept", "application/json") + + var lastErr error + for attempt := 0; attempt < musicBrainzRequestRetries; attempt++ { + waitForMusicBrainzRequestSlot() + + resp, err := client.Do(req) + if err == nil && resp != nil && resp.StatusCode == http.StatusOK { + defer resp.Body.Close() + + var mbResp MusicBrainzRecordingResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&mbResp); decodeErr != nil { + return nil, decodeErr + } + + return &mbResp, nil + } + + if err != nil { + lastErr = err + } else if resp == nil { + lastErr = fmt.Errorf("empty response from MusicBrainz") + } else { + if resp.StatusCode == http.StatusServiceUnavailable { + noteMusicBrainzThrottle() + } + lastErr = &musicBrainzStatusError{StatusCode: resp.StatusCode} + resp.Body.Close() + } + + if attempt < musicBrainzRequestRetries-1 && shouldRetryMusicBrainzRequest(lastErr) { + time.Sleep(musicBrainzRequestRetryWait) + continue + } + + break + } + + if lastErr == nil { + lastErr = fmt.Errorf("empty response from MusicBrainz") + } + + return nil, lastErr +} + func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) { var meta Metadata + var resultErr error if !embedGenre { return meta, nil } if isrc == "" { - return meta, fmt.Errorf("no ISRC provided") + resultErr = fmt.Errorf("no ISRC provided") + return meta, resultErr } + cacheKey := musicBrainzCacheKey(isrc, useSingleGenre) + if cached, ok := musicBrainzCache.Load(cacheKey); ok { + return cached.(Metadata), nil + } + + if ShouldSkipMusicBrainzMetadataFetch() { + resultErr = fmt.Errorf("skipping MusicBrainz lookup because the latest status check reported offline") + return meta, resultErr + } + + musicBrainzInflightMu.Lock() + if call, ok := musicBrainzInflight[cacheKey]; ok { + musicBrainzInflightMu.Unlock() + <-call.done + return call.result, call.err + } + + call := &musicBrainzInflightCall{done: make(chan struct{})} + musicBrainzInflight[cacheKey] = call + musicBrainzInflightMu.Unlock() + + defer func() { + call.result = meta + call.err = resultErr + + musicBrainzInflightMu.Lock() + delete(musicBrainzInflight, cacheKey) + close(call.done) + musicBrainzInflightMu.Unlock() + }() + client := &http.Client{ - Timeout: 10 * time.Second, + Timeout: musicBrainzRequestTimeout, } query := fmt.Sprintf("isrc:%s", isrc) - reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query)) - - req, err := http.NewRequest("GET", reqURL, nil) + mbResp, err := queryMusicBrainzRecordings(client, query) if err != nil { - return meta, err - } - - req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion)) - - var resp *http.Response - var lastErr error - - for i := 0; i < 3; i++ { - resp, lastErr = client.Do(req) - if lastErr == nil && resp.StatusCode == http.StatusOK { - break - } - - if resp != nil { - resp.Body.Close() - } - - if i < 2 { - time.Sleep(2 * time.Second) - } - } - - if lastErr != nil { - return meta, lastErr - } - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) - } - defer resp.Body.Close() - - var mbResp MusicBrainzRecordingResponse - if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil { - return meta, err + resultErr = err + return meta, resultErr } if len(mbResp.Recordings) == 0 { - return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc) + resultErr = fmt.Errorf("no recordings found for ISRC: %s", isrc) + return meta, resultErr } recording := mbResp.Recordings[0] @@ -150,5 +320,12 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre } } + if meta.Genre == "" { + resultErr = fmt.Errorf("no genre tags found in MusicBrainz") + return meta, resultErr + } + + musicBrainzCache.Store(cacheKey, meta) + return meta, nil } diff --git a/backend/qobuz.go b/backend/qobuz.go index 0117a2b..ba1d36b 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -72,21 +73,41 @@ func NewQobuzDownloader() *QobuzDownloader { client: &http.Client{ Timeout: 60 * time.Second, }, - appID: "798273057", + appID: qobuzDefaultAPIAppID, } } func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) { - apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query=" - url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID) + if strings.HasPrefix(isrc, "qobuz_") { + trackID := strings.TrimPrefix(isrc, "qobuz_") + resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client) + if err != nil { + return nil, fmt.Errorf("failed to fetch track: %w", err) + } + defer resp.Body.Close() - resp, err := q.client.Get(url) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var trackResp QobuzTrack + if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &trackResp, nil + } + + resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{ + "query": {isrc}, + "limit": {"1"}, + }, q.client) if err != nil { return nil, fmt.Errorf("failed to search track: %w", err) } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) } @@ -305,8 +326,12 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error { return err } -func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { +func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string { var filename string + isrc := "" + if len(extra) > 0 { + isrc = SanitizeOptionalFilename(extra[0]) + } numberToUse := position if useAlbumTrackNumber && trackNumber > 0 { @@ -326,6 +351,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) + filename = strings.ReplaceAll(filename, "{isrc}", isrc) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) @@ -360,7 +386,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { var isrc string if spotifyID != "" { linkClient := NewSongLinkClient() @@ -373,22 +399,27 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF return "", fmt.Errorf("spotify ID is required for Qobuz download") } - return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) + return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } -func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", isrc) metaChan := make(chan Metadata, 1) if embedGenre && isrc != "" { go func() { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { - fmt.Println("✓ MusicBrainz metadata fetched") - metaChan <- fetchedMeta - } else { - fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + if ShouldSkipMusicBrainzMetadataFetch() { + fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") metaChan <- Metadata{} + } else { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil { + fmt.Println("✓ MusicBrainz metadata fetched") + metaChan <- fetchedMeta + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + metaChan <- Metadata{} + } } }() } else { @@ -446,11 +477,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena safeTitle := sanitizeFilename(trackTitle) safeAlbum := sanitizeFilename(albumTitle) - filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrc) filepath := filepath.Join(outputDir, filename) - - if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024)) + filepath, alreadyExists := ResolveOutputPathForDownload(filepath, GetRedownloadWithSuffixSetting()) + if alreadyExists { + fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(mustFileSize(filepath))/(1024*1024)) return "EXISTS:" + filepath, nil } @@ -487,6 +518,14 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena trackNumberToEmbed = 1 } + upc := "" + if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { + if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { + isrc = strings.TrimSpace(identifiers.ISRC) + } + upc = strings.TrimSpace(identifiers.UPC) + } + metadata := Metadata{ Title: trackTitle, Artist: artists, @@ -501,8 +540,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, - Description: "https://github.com/afkarxyz/SpotiFLAC", + Composer: spotifyComposer, + Separator: metadataSeparator, + Description: "https://github.com/spotbye/SpotiFLAC", ISRC: isrc, + UPC: upc, Genre: mbMeta.Genre, } diff --git a/backend/qobuz_api.go b/backend/qobuz_api.go new file mode 100644 index 0000000..de5bfb9 --- /dev/null +++ b/backend/qobuz_api.go @@ -0,0 +1,407 @@ +package backend + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +const ( + qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2" + qobuzDefaultAPIAppID = "712109809" + qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1" + qobuzDefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + qobuzCredentialsCacheFile = "qobuz-api-credentials.json" + qobuzCredentialsCacheTTL = 24 * time.Hour + qobuzCredentialsProbeTrackISRC = "USUM71703861" + qobuzOpenTrackProbeURL = "https://open.qobuz.com/track/1" +) + +var ( + qobuzCredentialsMu sync.Mutex + qobuzCachedCredentials *qobuzAPICredentials + qobuzOpenBundleScriptPattern = regexp.MustCompile(`]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`) + qobuzOpenAPIConfigPattern = regexp.MustCompile(`app_id:"(?P\d{9})",app_secret:"(?P[a-f0-9]{32})"`) +) + +type qobuzAPICredentials struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Source string `json:"source,omitempty"` + FetchedAtUnix int64 `json:"fetched_at_unix"` +} + +type qobuzCredentialProbeResponse struct { + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` +} + +func defaultQobuzAPICredentials() *qobuzAPICredentials { + return &qobuzAPICredentials{ + AppID: qobuzDefaultAPIAppID, + AppSecret: qobuzDefaultAPIAppSecret, + Source: "embedded-default", + FetchedAtUnix: time.Now().Unix(), + } +} + +func qobuzCredentialsCachePath() (string, error) { + appDir, err := GetFFmpegDir() + if err != nil { + return "", err + } + return filepath.Join(appDir, qobuzCredentialsCacheFile), nil +} + +func loadQobuzCachedCredentials() (*qobuzAPICredentials, error) { + cachePath, err := qobuzCredentialsCachePath() + if err != nil { + return nil, err + } + + body, err := os.ReadFile(cachePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read qobuz credentials cache: %w", err) + } + + var creds qobuzAPICredentials + if err := json.Unmarshal(body, &creds); err != nil { + return nil, fmt.Errorf("failed to parse qobuz credentials cache: %w", err) + } + + if strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return nil, fmt.Errorf("qobuz credentials cache is incomplete") + } + + return &creds, nil +} + +func saveQobuzCachedCredentials(creds *qobuzAPICredentials) error { + if creds == nil { + return fmt.Errorf("qobuz credentials are required") + } + + cachePath, err := qobuzCredentialsCachePath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return fmt.Errorf("failed to create qobuz credentials cache directory: %w", err) + } + + body, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(cachePath, body, 0o644); err != nil { + return fmt.Errorf("failed to write qobuz credentials cache: %w", err) + } + + return nil +} + +func qobuzCredentialsCacheIsFresh(creds *qobuzAPICredentials) bool { + if creds == nil || creds.FetchedAtUnix == 0 || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return false + } + return time.Since(time.Unix(creds.FetchedAtUnix, 0)) < qobuzCredentialsCacheTTL +} + +func scrapeQobuzOpenCredentials(client *http.Client) (*qobuzAPICredentials, error) { + req, err := http.NewRequest(http.MethodGet, qobuzOpenTrackProbeURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", qobuzDefaultUA) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch open.qobuz.com shell: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("open.qobuz.com returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview))) + } + + htmlBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read open.qobuz.com shell: %w", err) + } + + scriptMatch := qobuzOpenBundleScriptPattern.FindStringSubmatch(string(htmlBody)) + if len(scriptMatch) < 2 { + return nil, fmt.Errorf("qobuz open bundle URL not found") + } + + bundleURL := strings.TrimSpace(scriptMatch[1]) + if strings.HasPrefix(bundleURL, "/") { + bundleURL = "https://open.qobuz.com" + bundleURL + } + if bundleURL == "" { + return nil, fmt.Errorf("qobuz open bundle URL is empty") + } + + bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil) + if err != nil { + return nil, err + } + bundleReq.Header.Set("User-Agent", qobuzDefaultUA) + + bundleResp, err := client.Do(bundleReq) + if err != nil { + return nil, fmt.Errorf("failed to fetch qobuz open bundle: %w", err) + } + defer bundleResp.Body.Close() + + if bundleResp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(bundleResp.Body, 512)) + return nil, fmt.Errorf("qobuz open bundle returned status %d: %s", bundleResp.StatusCode, strings.TrimSpace(string(preview))) + } + + bundleBody, err := io.ReadAll(bundleResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read qobuz open bundle: %w", err) + } + + configMatch := qobuzOpenAPIConfigPattern.FindStringSubmatch(string(bundleBody)) + if len(configMatch) < 3 { + return nil, fmt.Errorf("qobuz api app_id/app_secret pair not found in open bundle") + } + + return &qobuzAPICredentials{ + AppID: strings.TrimSpace(configMatch[1]), + AppSecret: strings.TrimSpace(configMatch[2]), + Source: bundleURL, + FetchedAtUnix: time.Now().Unix(), + }, nil +} + +func qobuzNormalizedPath(path string) string { + return strings.Trim(strings.TrimSpace(path), "/") +} + +func qobuzSignaturePayload(path string, params url.Values, timestamp string, secret string) string { + normalizedPath := strings.ReplaceAll(qobuzNormalizedPath(path), "/", "") + keys := make([]string, 0, len(params)) + for key := range params { + switch key { + case "app_id", "request_ts", "request_sig": + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + var builder strings.Builder + builder.WriteString(normalizedPath) + for _, key := range keys { + values := params[key] + if len(values) == 0 { + builder.WriteString(key) + continue + } + for _, value := range values { + builder.WriteString(key) + builder.WriteString(value) + } + } + builder.WriteString(timestamp) + builder.WriteString(secret) + return builder.String() +} + +func qobuzRequestSignature(path string, params url.Values, timestamp string, secret string) string { + sum := md5.Sum([]byte(qobuzSignaturePayload(path, params, timestamp, secret))) + return hex.EncodeToString(sum[:]) +} + +func newQobuzSignedRequestWithCredentials(method string, path string, params url.Values, creds *qobuzAPICredentials) (*http.Request, error) { + normalizedPath := qobuzNormalizedPath(path) + if normalizedPath == "" { + return nil, fmt.Errorf("qobuz request path is empty") + } + if creds == nil || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return nil, fmt.Errorf("qobuz credentials are incomplete") + } + + clonedParams := url.Values{} + for key, values := range params { + for _, value := range values { + clonedParams.Add(key, value) + } + } + + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + clonedParams.Set("app_id", creds.AppID) + clonedParams.Set("request_ts", timestamp) + clonedParams.Set("request_sig", qobuzRequestSignature(normalizedPath, params, timestamp, creds.AppSecret)) + + reqURL := fmt.Sprintf("%s/%s?%s", qobuzAPIBaseURL, normalizedPath, clonedParams.Encode()) + req, err := http.NewRequest(method, reqURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", qobuzDefaultUA) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-App-Id", creds.AppID) + + return req, nil +} + +func qobuzCredentialsSupportSignedMetadata(client *http.Client, creds *qobuzAPICredentials) bool { + if creds == nil { + return false + } + + req, err := newQobuzSignedRequestWithCredentials(http.MethodGet, "track/search", url.Values{ + "query": {qobuzCredentialsProbeTrackISRC}, + "limit": {"1"}, + }, creds) + if err != nil { + return false + } + + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false + } + + var payload qobuzCredentialProbeResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return false + } + + return payload.Tracks.Total > 0 +} + +func getQobuzAPICredentials(forceRefresh bool) (*qobuzAPICredentials, error) { + qobuzCredentialsMu.Lock() + defer qobuzCredentialsMu.Unlock() + + if !forceRefresh && qobuzCredentialsCacheIsFresh(qobuzCachedCredentials) { + return qobuzCachedCredentials, nil + } + + cachedFromDisk, diskErr := loadQobuzCachedCredentials() + if diskErr != nil { + fmt.Printf("Warning: failed to read Qobuz credentials cache: %v\n", diskErr) + } + if !forceRefresh && qobuzCredentialsCacheIsFresh(cachedFromDisk) { + qobuzCachedCredentials = cachedFromDisk + return qobuzCachedCredentials, nil + } + + client := &http.Client{Timeout: 30 * time.Second} + scrapedCreds, scrapeErr := scrapeQobuzOpenCredentials(client) + if scrapeErr == nil { + if qobuzCredentialsSupportSignedMetadata(client, scrapedCreds) { + qobuzCachedCredentials = scrapedCreds + if err := saveQobuzCachedCredentials(scrapedCreds); err != nil { + fmt.Printf("Warning: failed to write Qobuz credentials cache: %v\n", err) + } + fmt.Printf("Loaded fresh Qobuz credentials from %s (app_id=%s)\n", scrapedCreds.Source, scrapedCreds.AppID) + return qobuzCachedCredentials, nil + } + scrapeErr = fmt.Errorf("scraped qobuz credentials did not pass validation") + } + + if cachedFromDisk != nil { + qobuzCachedCredentials = cachedFromDisk + fmt.Printf("Warning: failed to refresh Qobuz credentials, using cached credentials: %v\n", scrapeErr) + return qobuzCachedCredentials, nil + } + + if qobuzCachedCredentials != nil { + fmt.Printf("Warning: failed to refresh Qobuz credentials, using in-memory credentials: %v\n", scrapeErr) + return qobuzCachedCredentials, nil + } + + fallback := defaultQobuzAPICredentials() + qobuzCachedCredentials = fallback + if scrapeErr != nil { + fmt.Printf("Warning: failed to refresh Qobuz credentials, using embedded fallback: %v\n", scrapeErr) + } + return qobuzCachedCredentials, nil +} + +func qobuzShouldRefreshCredentials(statusCode int) bool { + return statusCode == http.StatusBadRequest || statusCode == http.StatusUnauthorized +} + +func newQobuzSignedRequest(method string, path string, params url.Values) (*http.Request, error) { + creds, err := getQobuzAPICredentials(false) + if err != nil { + return nil, err + } + return newQobuzSignedRequestWithCredentials(method, path, params, creds) +} + +func doQobuzSignedRequest(method string, path string, params url.Values, client *http.Client) (*http.Response, error) { + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + + call := func(forceRefresh bool) (*http.Response, error) { + creds, err := getQobuzAPICredentials(forceRefresh) + if err != nil { + return nil, err + } + req, err := newQobuzSignedRequestWithCredentials(method, path, params, creds) + if err != nil { + return nil, err + } + return client.Do(req) + } + + resp, err := call(false) + if err != nil { + return nil, err + } + + if qobuzShouldRefreshCredentials(resp.StatusCode) { + resp.Body.Close() + return call(true) + } + + return resp, nil +} + +func doQobuzSignedJSONRequest(path string, params url.Values, target interface{}) error { + resp, err := doQobuzSignedRequest(http.MethodGet, path, params, &http.Client{Timeout: 20 * time.Second}) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("qobuz request failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} diff --git a/backend/recent_fetches.go b/backend/recent_fetches.go new file mode 100644 index 0000000..34d30fd --- /dev/null +++ b/backend/recent_fetches.go @@ -0,0 +1,91 @@ +package backend + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" +) + +const recentFetchesFileName = "recent_fetches.json" + +type RecentFetchItem struct { + ID string `json:"id"` + URL string `json:"url"` + Type string `json:"type"` + Name string `json:"name"` + Artist string `json:"artist"` + Image string `json:"image"` + Timestamp int64 `json:"timestamp"` +} + +var ( + recentFetchesMu sync.Mutex + recentFetchesDirResolver = GetFFmpegDir +) + +func recentFetchesFilePath() (string, error) { + baseDir, err := recentFetchesDirResolver() + if err != nil { + return "", err + } + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return "", err + } + return filepath.Join(baseDir, recentFetchesFileName), nil +} + +func LoadRecentFetches() ([]RecentFetchItem, error) { + recentFetchesMu.Lock() + defer recentFetchesMu.Unlock() + + filePath, err := recentFetchesFilePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return []RecentFetchItem{}, nil + } + return nil, err + } + + if strings.TrimSpace(string(data)) == "" { + return []RecentFetchItem{}, nil + } + + var items []RecentFetchItem + if err := json.Unmarshal(data, &items); err != nil { + return nil, err + } + + if items == nil { + return []RecentFetchItem{}, nil + } + + return items, nil +} + +func SaveRecentFetches(items []RecentFetchItem) error { + recentFetchesMu.Lock() + defer recentFetchesMu.Unlock() + + filePath, err := recentFetchesFilePath() + if err != nil { + return err + } + + if items == nil { + items = []RecentFetchItem{} + } + + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0o644) +} diff --git a/backend/songlink.go b/backend/songlink.go index 919fa39..8113a21 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -47,6 +47,16 @@ type songLinkAPIResponse struct { } `json:"linksByPlatform"` } +type qobuzAvailabilityTrack struct { + ID int64 `json:"id"` + Album struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + RelativeURL string `json:"relative_url"` + } `json:"album"` +} + func NewSongLinkClient() *SongLinkClient { return &SongLinkClient{ client: &http.Client{ @@ -114,7 +124,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv } if isrc != "" { - availability.Qobuz = checkQobuzAvailability(isrc) + availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc) } if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz { @@ -128,36 +138,90 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv return availability, fmt.Errorf("no platforms found") } -func checkQobuzAvailability(isrc string) bool { - client := &http.Client{Timeout: 10 * time.Second} - appID := "798273057" - - searchURL := fmt.Sprintf( - "https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", - url.QueryEscape(strings.TrimSpace(isrc)), - appID, - ) - - resp, err := client.Get(searchURL) - if err != nil { - return false +func qobuzNormalizeRelativeURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" } - defer resp.Body.Close() + if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") { + return rawURL + } + if strings.HasPrefix(rawURL, "/") { + return "https://www.qobuz.com" + rawURL + } + return "https://www.qobuz.com/" + rawURL +} - if resp.StatusCode != http.StatusOK { - return false +func qobuzSlugifySegment(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" } + var builder strings.Builder + lastDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + builder.WriteRune(r) + lastDash = false + default: + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + } + + return strings.Trim(builder.String(), "-") +} + +func qobuzAlbumSlugURL(albumTitle string, albumID string) string { + albumID = strings.TrimSpace(albumID) + if albumID == "" { + return "" + } + + slug := qobuzSlugifySegment(albumTitle) + if slug == "" { + return fmt.Sprintf("https://www.qobuz.com/album/%s", albumID) + } + + return fmt.Sprintf("https://www.qobuz.com/album/%s/%s", slug, albumID) +} + +func checkQobuzAvailability(isrc string) (bool, string) { var searchResp struct { Tracks struct { - Total int `json:"total"` + Total int `json:"total"` + Items []qobuzAvailabilityTrack `json:"items"` } `json:"tracks"` } - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return false + + if err := doQobuzSignedJSONRequest("track/search", url.Values{ + "query": {strings.TrimSpace(isrc)}, + "limit": {"1"}, + }, &searchResp); err != nil { + return false, "" } - return searchResp.Tracks.Total > 0 + if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 { + return false, "" + } + + item := searchResp.Tracks.Items[0] + qobuzURL := strings.TrimSpace(item.Album.URL) + if qobuzURL == "" { + qobuzURL = qobuzNormalizeRelativeURL(item.Album.RelativeURL) + } + if qobuzURL == "" { + qobuzURL = qobuzAlbumSlugURL(item.Album.Title, item.Album.ID) + } + if qobuzURL == "" && item.ID > 0 { + qobuzURL = fmt.Sprintf("https://www.qobuz.com/us-en/track/%d", item.ID) + } + + return true, qobuzURL } func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 4ca171c..f168005 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -15,9 +15,6 @@ import ( "time" "sort" - - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" ) var SpotifyError = errors.New("spotify error") @@ -40,21 +37,7 @@ func NewSpotifyClient() *SpotifyClient { } func (c *SpotifyClient) generateTOTP() (string, int, error) { - - secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY" - version := 61 - - key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret)) - if err != nil { - return "", 0, err - } - - totpCode, err := totp.GenerateCode(key.Secret(), time.Now()) - if err != nil { - return "", 0, err - } - - return totpCode, version, nil + return generateSpotifyTOTP(time.Now()) } func (c *SpotifyClient) getAccessToken() error { diff --git a/backend/spotfetch_api.go b/backend/spotfetch_api.go deleted file mode 100644 index db484c0..0000000 --- a/backend/spotfetch_api.go +++ /dev/null @@ -1,185 +0,0 @@ -package backend - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" -) - -func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error { - if callback == nil || len(tracks) == 0 { - return nil - } - - const chunkSize = 25 - for start := 0; start < len(tracks); start += chunkSize { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - end := start + chunkSize - if end > len(tracks) { - end = len(tracks) - } - - callback(tracks[start:end]) - - if end < len(tracks) { - time.Sleep(15 * time.Millisecond) - } - } - - return nil -} - -func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) { - if !useAPI || apiBaseURL == "" { - return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) - } - - spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL) - if spotifyType == "" || id == "" { - return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL) - } - - if spotifyType == "artist" { - return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) - } - - apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id) - - req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create API request: %w", err) - } - - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("SpotFetch API request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode) - } - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read API response: %w", err) - } - - var data interface{} - - switch spotifyType { - case "track": - var trackResp TrackResponse - if err := json.Unmarshal(bodyBytes, &trackResp); err != nil { - return nil, fmt.Errorf("failed to decode track response: %w", err) - } - data = trackResp - case "album": - var albumResp AlbumResponsePayload - if err := json.Unmarshal(bodyBytes, &albumResp); err != nil { - return nil, fmt.Errorf("failed to decode album response: %w", err) - } - data = &albumResp - if callback != nil { - callback(&AlbumResponsePayload{ - AlbumInfo: albumResp.AlbumInfo, - TrackList: []AlbumTrackMetadata{}, - }) - if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil { - return nil, err - } - } - case "playlist": - var playlistResp PlaylistResponsePayload - if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil { - return nil, fmt.Errorf("failed to decode playlist response: %w", err) - } - data = playlistResp - if callback != nil { - callback(PlaylistResponsePayload{ - PlaylistInfo: playlistResp.PlaylistInfo, - TrackList: []AlbumTrackMetadata{}, - }) - if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil { - return nil, err - } - } - case "artist": - var artistResp ArtistDiscographyPayload - if err := json.Unmarshal(bodyBytes, &artistResp); err != nil { - return nil, fmt.Errorf("failed to decode artist response: %w", err) - } - data = &artistResp - if callback != nil { - callback(&ArtistDiscographyPayload{ - ArtistInfo: artistResp.ArtistInfo, - AlbumList: artistResp.AlbumList, - TrackList: []AlbumTrackMetadata{}, - }) - if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil { - return nil, err - } - } - default: - return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType) - } - - if callback != nil { - switch payload := data.(type) { - case TrackResponse: - t := payload.Track - callback([]AlbumTrackMetadata{{ - SpotifyID: t.SpotifyID, - Artists: t.Artists, - Name: t.Name, - AlbumName: t.AlbumName, - AlbumArtist: t.AlbumArtist, - DurationMS: t.DurationMS, - Images: t.Images, - ReleaseDate: t.ReleaseDate, - TrackNumber: t.TrackNumber, - TotalTracks: t.TotalTracks, - DiscNumber: t.DiscNumber, - TotalDiscs: t.TotalDiscs, - ExternalURL: t.ExternalURL, - Plays: t.Plays, - PreviewURL: t.PreviewURL, - IsExplicit: t.IsExplicit, - }}) - } - } - - return data, nil -} - -func parseSpotifyURLToTypeAndID(url string) (string, string) { - - if strings.HasPrefix(url, "spotify:") { - parts := strings.Split(url, ":") - if len(parts) >= 3 { - return parts[1], parts[2] - } - } - - re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`) - matches := re.FindStringSubmatch(url) - if len(matches) == 3 { - return matches[1], matches[2] - } - - return "", "" -} diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 3d28633..35129ef 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -33,24 +33,31 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { } type TrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ExternalURL string `json:"external_urls"` - Copyright string `json:"copyright,omitempty"` - Publisher string `json:"publisher,omitempty"` - Plays string `json:"plays,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - IsExplicit bool `json:"is_explicit,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ExternalURL string `json:"external_urls"` + AlbumID string `json:"album_id,omitempty"` + AlbumURL string `json:"album_url,omitempty"` + ArtistID string `json:"artist_id,omitempty"` + ArtistURL string `json:"artist_url,omitempty"` + ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + UPC string `json:"upc,omitempty"` + Copyright string `json:"copyright,omitempty"` + Publisher string `json:"publisher,omitempty"` + Composer string `json:"composer,omitempty"` + Plays string `json:"plays,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + IsExplicit bool `json:"is_explicit,omitempty"` } type ArtistSimple struct { @@ -79,6 +86,7 @@ type AlbumTrackMetadata struct { ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + UPC string `json:"upc,omitempty"` Plays string `json:"plays,omitempty"` Status string `json:"status,omitempty"` PreviewURL string `json:"preview_url,omitempty"` @@ -95,6 +103,7 @@ type AlbumInfoMetadata struct { ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` + UPC string `json:"upc,omitempty"` Batch string `json:"batch,omitempty"` ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` @@ -179,15 +188,18 @@ type spotifyURI struct { } type apiTrackResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Duration string `json:"duration"` - Track int `json:"track"` - Disc int `json:"disc"` - Discs int `json:"discs"` - Copyright string `json:"copyright"` - Plays string `json:"plays"` + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + ArtistIds []string `json:"artistIds,omitempty"` + UPC string `json:"upc,omitempty"` + Duration string `json:"duration"` + Track int `json:"track"` + Disc int `json:"disc"` + Discs int `json:"discs"` + Copyright string `json:"copyright"` + Composer string `json:"composer,omitempty"` + Plays string `json:"plays"` Album struct { ID string `json:"id"` Name string `json:"name"` @@ -211,6 +223,7 @@ type apiAlbumResponse struct { Artists string `json:"artists"` Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` + UPC string `json:"upc,omitempty"` Count int `json:"count"` Label string `json:"label"` Discs struct { @@ -223,6 +236,7 @@ type apiAlbumResponse struct { ArtistIds []string `json:"artistIds"` Duration string `json:"duration"` Plays string `json:"plays"` + UPC string `json:"upc,omitempty"` IsExplicit bool `json:"is_explicit"` DiscNumber int `json:"disc_number"` } `json:"tracks"` @@ -250,6 +264,7 @@ type apiPlaylistResponse struct { Album string `json:"album"` AlbumArtist string `json:"albumArtist"` AlbumID string `json:"albumId"` + UPC string `json:"upc,omitempty"` Duration string `json:"duration"` IsExplicit bool `json:"is_explicit"` DiscNumber int `json:"disc_number"` @@ -490,6 +505,10 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) } filteredData := FilterTrack(data, c.Separator, albumFetchData) + composer, composerErr := c.fetchTrackComposerWithClient(ctx, client, trackID) + if composerErr == nil && composer != "" { + filteredData["composer"] = composer + } jsonData, err := json.Marshal(filteredData) if err != nil { @@ -501,9 +520,100 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) return nil, fmt.Errorf("failed to unmarshal to apiTrackResponse: %w", err) } + if result.ID != "" { + if identifiers, err := GetSpotifyTrackIdentifiersDirect(result.ID); err == nil || identifiers.UPC != "" { + if identifiers.UPC != "" { + result.UPC = identifiers.UPC + } + } + } + return &result, nil } +func collectTrackCreditNamesByRole(items []interface{}, role string) []string { + role = strings.TrimSpace(role) + if role == "" || len(items) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(items)) + names := make([]string, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + if !strings.EqualFold(strings.TrimSpace(getString(itemMap, "role")), role) { + continue + } + + name := strings.TrimSpace(getString(itemMap, "name")) + if name == "" { + continue + } + if _, exists := seen[name]; exists { + continue + } + + seen[name] = struct{}{} + names = append(names, name) + } + + return names +} + +func (c *SpotifyMetadataClient) fetchTrackComposerWithClient(ctx context.Context, client *SpotifyClient, trackID string) (string, error) { + _ = ctx + + payload := map[string]interface{}{ + "variables": map[string]interface{}{ + "trackUri": fmt.Sprintf("spotify:track:%s", trackID), + "contributorsLimit": 100, + "contributorsOffset": 0, + }, + "operationName": "queryTrackCreditsModal", + "extensions": map[string]interface{}{ + "persistedQuery": map[string]interface{}{ + "version": 1, + "sha256Hash": "e2ca40d46cf1fde36562261ccec754f23fb31b561877252e9fe0d6834aabb84b", + }, + }, + } + + data, err := client.Query(payload) + if err != nil { + return "", fmt.Errorf("failed to query track credits: %w", err) + } + + creditItems := getSlice( + getMap( + getMap( + getMap( + getMap(data, "data"), + "trackUnion", + ), + "creditsTrait", + ), + "contributors", + ), + "items", + ) + + composerNames := collectTrackCreditNamesByRole(creditItems, "Composer") + if len(composerNames) == 0 { + return "", nil + } + + separator := strings.TrimSpace(c.Separator) + if separator == "" { + separator = ", " + } + + return strings.Join(composerNames, separator), nil +} + func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { client := NewSpotifyClient() if err := client.Initialize(); err != nil { @@ -607,6 +717,17 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client return nil, fmt.Errorf("failed to unmarshal to apiAlbumResponse: %w", err) } + if result.ID != "" { + if upc, err := lookupSpotifyAlbumUPC(result.ID); err == nil && strings.TrimSpace(upc) != "" { + result.UPC = upc + for i := range result.Tracks { + if strings.TrimSpace(result.Tracks[i].UPC) == "" { + result.Tracks[i].UPC = upc + } + } + } + } + return &result, nil } @@ -895,6 +1016,34 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp durationMS := parseDuration(raw.Duration) externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID) + albumID := strings.TrimSpace(raw.Album.ID) + albumURL := "" + if albumID != "" { + albumURL = fmt.Sprintf("https://open.spotify.com/album/%s", albumID) + } + artistID := "" + artistURL := "" + artistsData := make([]ArtistSimple, 0, len(raw.ArtistIds)) + for index, id := range raw.ArtistIds { + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + continue + } + if artistID == "" { + artistID = trimmedID + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID) + } + artistName := "" + artistNames := splitAndCleanArtists(raw.Artists) + if index < len(artistNames) { + artistName = artistNames[index] + } + artistsData = append(artistsData, ArtistSimple{ + ID: trimmedID, + Name: artistName, + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID), + }) + } coverURL := raw.Cover.Small if coverURL == "" { @@ -922,8 +1071,15 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp DiscNumber: raw.Disc, TotalDiscs: raw.Discs, ExternalURL: externalURL, + AlbumID: albumID, + AlbumURL: albumURL, + ArtistID: artistID, + ArtistURL: artistURL, + ArtistsData: artistsData, + UPC: raw.UPC, Copyright: raw.Copyright, Publisher: raw.Album.Label, + Composer: raw.Composer, Plays: raw.Plays, IsExplicit: raw.IsExplicit, } @@ -935,6 +1091,18 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) { var artistID, artistURL string + for _, item := range raw.Tracks { + if len(item.ArtistIds) == 0 { + continue + } + candidate := strings.TrimSpace(item.ArtistIds[0]) + if candidate == "" { + continue + } + artistID = candidate + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", candidate) + break + } info := AlbumInfoMetadata{ TotalTracks: raw.Count, @@ -942,6 +1110,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback ReleaseDate: raw.ReleaseDate, Artists: raw.Artists, Images: raw.Cover, + UPC: raw.UPC, ArtistID: artistID, ArtistURL: artistURL, } @@ -957,6 +1126,10 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback for idx, item := range raw.Tracks { durationMS := parseDuration(item.Duration) trackNumber := idx + 1 + trackUPC := strings.TrimSpace(item.UPC) + if trackUPC == "" { + trackUPC = strings.TrimSpace(raw.UPC) + } var artistID, artistURL string if len(item.ArtistIds) > 0 { @@ -992,6 +1165,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: trackUPC, Plays: item.Plays, IsExplicit: item.IsExplicit, }) @@ -1062,6 +1236,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, cal ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + UPC: item.UPC, Plays: item.Plays, Status: item.Status, IsExplicit: item.IsExplicit, @@ -1188,6 +1363,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, TrackNumber: trackNumber, TotalTracks: albumData.Count, DiscNumber: tr.DiscNumber, + UPC: tr.UPC, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), AlbumID: albumID, AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID), @@ -1321,6 +1497,18 @@ func parseArtistIDsFromString(artists string) []string { return []string{} } +func splitAndCleanArtists(artists string) []string { + raw := regexp.MustCompile(`\s*[;,]\s*`).Split(strings.TrimSpace(artists), -1) + parts := make([]string, 0, len(raw)) + for _, part := range raw { + part = strings.TrimSpace(part) + if part != "" { + parts = append(parts, part) + } + } + return parts +} + func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) { if query == "" { return nil, errors.New("search query cannot be empty") diff --git a/backend/spotify_totp.go b/backend/spotify_totp.go new file mode 100644 index 0000000..3f5faa5 --- /dev/null +++ b/backend/spotify_totp.go @@ -0,0 +1,28 @@ +package backend + +import ( + "fmt" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + spotifyTOTPSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY" + spotifyTOTPVersion = 61 +) + +func generateSpotifyTOTP(now time.Time) (string, int, error) { + key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", spotifyTOTPSecret)) + if err != nil { + return "", 0, err + } + + code, err := totp.GenerateCode(key.Secret(), now) + if err != nil { + return "", 0, err + } + + return code, spotifyTOTPVersion, nil +} diff --git a/backend/tidal.go b/backend/tidal.go index 402c81e..ee16973 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -416,7 +416,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -449,11 +449,12 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) outputFilename := filepath.Join(outputDir, filename) - if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) + if alreadyExists { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) return "EXISTS:" + outputFilename, nil } @@ -492,12 +493,16 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } res.ISRC = isrc if isrc != "" { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + if ShouldSkipMusicBrainzMetadataFetch() { + fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") } else { - fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } } } metaChan <- res @@ -511,11 +516,13 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return "", err } - var isrc string + isrc := strings.TrimSpace(isrcOverride) var mbMeta Metadata if spotifyURL != "" { result := <-metaChan - isrc = result.ISRC + if isrc == "" { + isrc = result.ISRC + } mbMeta = result.Metadata } @@ -554,7 +561,9 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, - Description: "https://github.com/afkarxyz/SpotiFLAC", + Composer: spotifyComposer, + Separator: metadataSeparator, + Description: "https://github.com/spotbye/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, } @@ -570,7 +579,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return outputFilename, nil } -func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -608,11 +617,12 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) outputFilename := filepath.Join(outputDir, filename) - if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) + if alreadyExists { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) return "EXISTS:" + outputFilename, nil } @@ -651,12 +661,16 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } res.ISRC = isrc if isrc != "" { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") + if ShouldSkipMusicBrainzMetadataFetch() { + fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") } else { - fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } } } metaChan <- res @@ -671,11 +685,13 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "", err } - var isrc string + isrc := strings.TrimSpace(isrcOverride) var mbMeta Metadata if spotifyURL != "" { result := <-metaChan - isrc = result.ISRC + if isrc == "" { + isrc = result.ISRC + } mbMeta = result.Metadata } @@ -714,7 +730,9 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality Comment: spotifyURL, Copyright: spotifyCopyright, Publisher: spotifyPublisher, - Description: "https://github.com/afkarxyz/SpotiFLAC", + Composer: spotifyComposer, + Separator: metadataSeparator, + Description: "https://github.com/spotbye/SpotiFLAC", ISRC: isrc, Genre: mbMeta.Genre, } @@ -730,14 +748,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return outputFilename, nil } -func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) } type SegmentTemplate struct { @@ -977,8 +995,12 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) } -func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { +func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string { var filename string + isrc := "" + if len(extra) > 0 { + isrc = SanitizeOptionalFilename(extra[0]) + } numberToUse := position if useAlbumTrackNumber && trackNumber > 0 { @@ -998,6 +1020,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{year}", year) filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) + filename = strings.ReplaceAll(filename, "{isrc}", isrc) if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) diff --git a/backend/upc_tags.go b/backend/upc_tags.go new file mode 100644 index 0000000..14a638f --- /dev/null +++ b/backend/upc_tags.go @@ -0,0 +1,50 @@ +package backend + +import "strings" + +const preferredUPCTagKey = "UPC" + +var ffprobeUPCTagKeys = []string{ + "upc", + "barcode", + "wm/upc", + "txxx:upc", + "txxx:barcode", + "txxx/upc", + "txxx/barcode", + "----:com.apple.itunes:upc", + "----:com.apple.itunes:barcode", +} + +func assignPreferredUPC(current *string, incoming string, preferred bool) { + incoming = strings.TrimSpace(incoming) + if incoming == "" { + return + } + + if preferred || strings.TrimSpace(*current) == "" { + *current = incoming + } +} + +func classifyUPCDescription(description string) (matched bool, preferred bool) { + switch strings.ToUpper(strings.TrimSpace(description)) { + case preferredUPCTagKey: + return true, true + case "BARCODE": + return true, false + default: + return false, false + } +} + +func firstPreferredFFprobeUPCValue(tags map[string]string) string { + for _, key := range ffprobeUPCTagKeys { + value := strings.TrimSpace(tags[key]) + if value != "" { + return value + } + } + + return "" +} diff --git a/frontend/public/assets/flags/ad.svg b/frontend/public/assets/flags/ad.svg new file mode 100644 index 0000000..199ff19 --- /dev/null +++ b/frontend/public/assets/flags/ad.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ae.svg b/frontend/public/assets/flags/ae.svg new file mode 100644 index 0000000..651ac85 --- /dev/null +++ b/frontend/public/assets/flags/ae.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/af.svg b/frontend/public/assets/flags/af.svg new file mode 100644 index 0000000..4dbe455 --- /dev/null +++ b/frontend/public/assets/flags/af.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ag.svg b/frontend/public/assets/flags/ag.svg new file mode 100644 index 0000000..243c3d8 --- /dev/null +++ b/frontend/public/assets/flags/ag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ai.svg b/frontend/public/assets/flags/ai.svg new file mode 100644 index 0000000..9c2ea33 --- /dev/null +++ b/frontend/public/assets/flags/ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/al.svg b/frontend/public/assets/flags/al.svg new file mode 100644 index 0000000..e85d95f --- /dev/null +++ b/frontend/public/assets/flags/al.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/am.svg b/frontend/public/assets/flags/am.svg new file mode 100644 index 0000000..99fa4dc --- /dev/null +++ b/frontend/public/assets/flags/am.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ao.svg b/frontend/public/assets/flags/ao.svg new file mode 100644 index 0000000..b73b1ec --- /dev/null +++ b/frontend/public/assets/flags/ao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/aq.svg b/frontend/public/assets/flags/aq.svg new file mode 100644 index 0000000..c7e3536 --- /dev/null +++ b/frontend/public/assets/flags/aq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ar.svg b/frontend/public/assets/flags/ar.svg new file mode 100644 index 0000000..c753da1 --- /dev/null +++ b/frontend/public/assets/flags/ar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/arab.svg b/frontend/public/assets/flags/arab.svg new file mode 100644 index 0000000..9ef079f --- /dev/null +++ b/frontend/public/assets/flags/arab.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/as.svg b/frontend/public/assets/flags/as.svg new file mode 100644 index 0000000..82459de --- /dev/null +++ b/frontend/public/assets/flags/as.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/asean.svg b/frontend/public/assets/flags/asean.svg new file mode 100644 index 0000000..189ae02 --- /dev/null +++ b/frontend/public/assets/flags/asean.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/at.svg b/frontend/public/assets/flags/at.svg new file mode 100644 index 0000000..9d2775c --- /dev/null +++ b/frontend/public/assets/flags/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/au.svg b/frontend/public/assets/flags/au.svg new file mode 100644 index 0000000..96e8076 --- /dev/null +++ b/frontend/public/assets/flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/aw.svg b/frontend/public/assets/flags/aw.svg new file mode 100644 index 0000000..413b7c4 --- /dev/null +++ b/frontend/public/assets/flags/aw.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ax.svg b/frontend/public/assets/flags/ax.svg new file mode 100644 index 0000000..0584d71 --- /dev/null +++ b/frontend/public/assets/flags/ax.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/az.svg b/frontend/public/assets/flags/az.svg new file mode 100644 index 0000000..3557522 --- /dev/null +++ b/frontend/public/assets/flags/az.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/ba.svg b/frontend/public/assets/flags/ba.svg new file mode 100644 index 0000000..93bd9cf --- /dev/null +++ b/frontend/public/assets/flags/ba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bb.svg b/frontend/public/assets/flags/bb.svg new file mode 100644 index 0000000..cecd5cc --- /dev/null +++ b/frontend/public/assets/flags/bb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/bd.svg b/frontend/public/assets/flags/bd.svg new file mode 100644 index 0000000..16b794d --- /dev/null +++ b/frontend/public/assets/flags/bd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/be.svg b/frontend/public/assets/flags/be.svg new file mode 100644 index 0000000..ac706a0 --- /dev/null +++ b/frontend/public/assets/flags/be.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/bf.svg b/frontend/public/assets/flags/bf.svg new file mode 100644 index 0000000..4713822 --- /dev/null +++ b/frontend/public/assets/flags/bf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/bg.svg b/frontend/public/assets/flags/bg.svg new file mode 100644 index 0000000..af2d0d0 --- /dev/null +++ b/frontend/public/assets/flags/bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/bh.svg b/frontend/public/assets/flags/bh.svg new file mode 100644 index 0000000..7a2ea54 --- /dev/null +++ b/frontend/public/assets/flags/bh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/bi.svg b/frontend/public/assets/flags/bi.svg new file mode 100644 index 0000000..a4434a9 --- /dev/null +++ b/frontend/public/assets/flags/bi.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bj.svg b/frontend/public/assets/flags/bj.svg new file mode 100644 index 0000000..0846724 --- /dev/null +++ b/frontend/public/assets/flags/bj.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bl.svg b/frontend/public/assets/flags/bl.svg new file mode 100644 index 0000000..f84cbba --- /dev/null +++ b/frontend/public/assets/flags/bl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/bm.svg b/frontend/public/assets/flags/bm.svg new file mode 100644 index 0000000..f43a5eb --- /dev/null +++ b/frontend/public/assets/flags/bm.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bn.svg b/frontend/public/assets/flags/bn.svg new file mode 100644 index 0000000..f544c25 --- /dev/null +++ b/frontend/public/assets/flags/bn.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bo.svg b/frontend/public/assets/flags/bo.svg new file mode 100644 index 0000000..7658e3f --- /dev/null +++ b/frontend/public/assets/flags/bo.svg @@ -0,0 +1,673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bq.svg b/frontend/public/assets/flags/bq.svg new file mode 100644 index 0000000..0e6bc76 --- /dev/null +++ b/frontend/public/assets/flags/bq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/br.svg b/frontend/public/assets/flags/br.svg new file mode 100644 index 0000000..719a763 --- /dev/null +++ b/frontend/public/assets/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bs.svg b/frontend/public/assets/flags/bs.svg new file mode 100644 index 0000000..5cc918e --- /dev/null +++ b/frontend/public/assets/flags/bs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bt.svg b/frontend/public/assets/flags/bt.svg new file mode 100644 index 0000000..20aef3a --- /dev/null +++ b/frontend/public/assets/flags/bt.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bv.svg b/frontend/public/assets/flags/bv.svg new file mode 100644 index 0000000..40e16d9 --- /dev/null +++ b/frontend/public/assets/flags/bv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bw.svg b/frontend/public/assets/flags/bw.svg new file mode 100644 index 0000000..3435608 --- /dev/null +++ b/frontend/public/assets/flags/bw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/by.svg b/frontend/public/assets/flags/by.svg new file mode 100644 index 0000000..948784f --- /dev/null +++ b/frontend/public/assets/flags/by.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/bz.svg b/frontend/public/assets/flags/bz.svg new file mode 100644 index 0000000..d81b16c --- /dev/null +++ b/frontend/public/assets/flags/bz.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ca.svg b/frontend/public/assets/flags/ca.svg new file mode 100644 index 0000000..c9b23b4 --- /dev/null +++ b/frontend/public/assets/flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/cc.svg b/frontend/public/assets/flags/cc.svg new file mode 100644 index 0000000..a42dec6 --- /dev/null +++ b/frontend/public/assets/flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cd.svg b/frontend/public/assets/flags/cd.svg new file mode 100644 index 0000000..b9cf528 --- /dev/null +++ b/frontend/public/assets/flags/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/cefta.svg b/frontend/public/assets/flags/cefta.svg new file mode 100644 index 0000000..f748d08 --- /dev/null +++ b/frontend/public/assets/flags/cefta.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cf.svg b/frontend/public/assets/flags/cf.svg new file mode 100644 index 0000000..a6cd367 --- /dev/null +++ b/frontend/public/assets/flags/cf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cg.svg b/frontend/public/assets/flags/cg.svg new file mode 100644 index 0000000..f5a0e42 --- /dev/null +++ b/frontend/public/assets/flags/cg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ch.svg b/frontend/public/assets/flags/ch.svg new file mode 100644 index 0000000..b42d670 --- /dev/null +++ b/frontend/public/assets/flags/ch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/ci.svg b/frontend/public/assets/flags/ci.svg new file mode 100644 index 0000000..e400f0c --- /dev/null +++ b/frontend/public/assets/flags/ci.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/ck.svg b/frontend/public/assets/flags/ck.svg new file mode 100644 index 0000000..18e547b --- /dev/null +++ b/frontend/public/assets/flags/ck.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/cl.svg b/frontend/public/assets/flags/cl.svg new file mode 100644 index 0000000..5b3c72f --- /dev/null +++ b/frontend/public/assets/flags/cl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cm.svg b/frontend/public/assets/flags/cm.svg new file mode 100644 index 0000000..70adc8b --- /dev/null +++ b/frontend/public/assets/flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cn.svg b/frontend/public/assets/flags/cn.svg new file mode 100644 index 0000000..10d3489 --- /dev/null +++ b/frontend/public/assets/flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/co.svg b/frontend/public/assets/flags/co.svg new file mode 100644 index 0000000..ebd0a0f --- /dev/null +++ b/frontend/public/assets/flags/co.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/cp.svg b/frontend/public/assets/flags/cp.svg new file mode 100644 index 0000000..b8aa9cf --- /dev/null +++ b/frontend/public/assets/flags/cp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/cr.svg b/frontend/public/assets/flags/cr.svg new file mode 100644 index 0000000..5a409ee --- /dev/null +++ b/frontend/public/assets/flags/cr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/cu.svg b/frontend/public/assets/flags/cu.svg new file mode 100644 index 0000000..053c9ee --- /dev/null +++ b/frontend/public/assets/flags/cu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cv.svg b/frontend/public/assets/flags/cv.svg new file mode 100644 index 0000000..aec8994 --- /dev/null +++ b/frontend/public/assets/flags/cv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cw.svg b/frontend/public/assets/flags/cw.svg new file mode 100644 index 0000000..bb0ece2 --- /dev/null +++ b/frontend/public/assets/flags/cw.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cx.svg b/frontend/public/assets/flags/cx.svg new file mode 100644 index 0000000..3a83c23 --- /dev/null +++ b/frontend/public/assets/flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/cy.svg b/frontend/public/assets/flags/cy.svg new file mode 100644 index 0000000..ee4b0c7 --- /dev/null +++ b/frontend/public/assets/flags/cy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/cz.svg b/frontend/public/assets/flags/cz.svg new file mode 100644 index 0000000..7913de3 --- /dev/null +++ b/frontend/public/assets/flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/de.svg b/frontend/public/assets/flags/de.svg new file mode 100644 index 0000000..71aa2d2 --- /dev/null +++ b/frontend/public/assets/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/dg.svg b/frontend/public/assets/flags/dg.svg new file mode 100644 index 0000000..dfee2bb --- /dev/null +++ b/frontend/public/assets/flags/dg.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/dj.svg b/frontend/public/assets/flags/dj.svg new file mode 100644 index 0000000..9b00a82 --- /dev/null +++ b/frontend/public/assets/flags/dj.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/dk.svg b/frontend/public/assets/flags/dk.svg new file mode 100644 index 0000000..563277f --- /dev/null +++ b/frontend/public/assets/flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/dm.svg b/frontend/public/assets/flags/dm.svg new file mode 100644 index 0000000..5aa9cea --- /dev/null +++ b/frontend/public/assets/flags/dm.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/do.svg b/frontend/public/assets/flags/do.svg new file mode 100644 index 0000000..6de2b26 --- /dev/null +++ b/frontend/public/assets/flags/do.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/dz.svg b/frontend/public/assets/flags/dz.svg new file mode 100644 index 0000000..5ff29a7 --- /dev/null +++ b/frontend/public/assets/flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/eac.svg b/frontend/public/assets/flags/eac.svg new file mode 100644 index 0000000..59d02d2 --- /dev/null +++ b/frontend/public/assets/flags/eac.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ec.svg b/frontend/public/assets/flags/ec.svg new file mode 100644 index 0000000..88c50bf --- /dev/null +++ b/frontend/public/assets/flags/ec.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ee.svg b/frontend/public/assets/flags/ee.svg new file mode 100644 index 0000000..8b98c2c --- /dev/null +++ b/frontend/public/assets/flags/ee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/eg.svg b/frontend/public/assets/flags/eg.svg new file mode 100644 index 0000000..88e32b3 --- /dev/null +++ b/frontend/public/assets/flags/eg.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/eh.svg b/frontend/public/assets/flags/eh.svg new file mode 100644 index 0000000..6aec728 --- /dev/null +++ b/frontend/public/assets/flags/eh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/er.svg b/frontend/public/assets/flags/er.svg new file mode 100644 index 0000000..48a13b4 --- /dev/null +++ b/frontend/public/assets/flags/er.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/es-ct.svg b/frontend/public/assets/flags/es-ct.svg new file mode 100644 index 0000000..4d85911 --- /dev/null +++ b/frontend/public/assets/flags/es-ct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/es-ga.svg b/frontend/public/assets/flags/es-ga.svg new file mode 100644 index 0000000..573ca45 --- /dev/null +++ b/frontend/public/assets/flags/es-ga.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/es-pv.svg b/frontend/public/assets/flags/es-pv.svg new file mode 100644 index 0000000..63c19f4 --- /dev/null +++ b/frontend/public/assets/flags/es-pv.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/es.svg b/frontend/public/assets/flags/es.svg new file mode 100644 index 0000000..a296ebf --- /dev/null +++ b/frontend/public/assets/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/et.svg b/frontend/public/assets/flags/et.svg new file mode 100644 index 0000000..3f99be4 --- /dev/null +++ b/frontend/public/assets/flags/et.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/eu.svg b/frontend/public/assets/flags/eu.svg new file mode 100644 index 0000000..b0874c1 --- /dev/null +++ b/frontend/public/assets/flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fi.svg b/frontend/public/assets/flags/fi.svg new file mode 100644 index 0000000..470be2d --- /dev/null +++ b/frontend/public/assets/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/fj.svg b/frontend/public/assets/flags/fj.svg new file mode 100644 index 0000000..332ae61 --- /dev/null +++ b/frontend/public/assets/flags/fj.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fk.svg b/frontend/public/assets/flags/fk.svg new file mode 100644 index 0000000..a0dace8 --- /dev/null +++ b/frontend/public/assets/flags/fk.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fm.svg b/frontend/public/assets/flags/fm.svg new file mode 100644 index 0000000..c1b7c97 --- /dev/null +++ b/frontend/public/assets/flags/fm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fo.svg b/frontend/public/assets/flags/fo.svg new file mode 100644 index 0000000..f802d28 --- /dev/null +++ b/frontend/public/assets/flags/fo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/fr.svg b/frontend/public/assets/flags/fr.svg new file mode 100644 index 0000000..e682b90 --- /dev/null +++ b/frontend/public/assets/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ga.svg b/frontend/public/assets/flags/ga.svg new file mode 100644 index 0000000..76edab4 --- /dev/null +++ b/frontend/public/assets/flags/ga.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/gb-eng.svg b/frontend/public/assets/flags/gb-eng.svg new file mode 100644 index 0000000..12e3b67 --- /dev/null +++ b/frontend/public/assets/flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/gb-nir.svg b/frontend/public/assets/flags/gb-nir.svg new file mode 100644 index 0000000..e22190a --- /dev/null +++ b/frontend/public/assets/flags/gb-nir.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gb-sct.svg b/frontend/public/assets/flags/gb-sct.svg new file mode 100644 index 0000000..f50cd32 --- /dev/null +++ b/frontend/public/assets/flags/gb-sct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/gb-wls.svg b/frontend/public/assets/flags/gb-wls.svg new file mode 100644 index 0000000..d7f5791 --- /dev/null +++ b/frontend/public/assets/flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/gb.svg b/frontend/public/assets/flags/gb.svg new file mode 100644 index 0000000..7991383 --- /dev/null +++ b/frontend/public/assets/flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/gd.svg b/frontend/public/assets/flags/gd.svg new file mode 100644 index 0000000..b3d250d --- /dev/null +++ b/frontend/public/assets/flags/gd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ge.svg b/frontend/public/assets/flags/ge.svg new file mode 100644 index 0000000..ab08a9a --- /dev/null +++ b/frontend/public/assets/flags/ge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/gf.svg b/frontend/public/assets/flags/gf.svg new file mode 100644 index 0000000..f8fe94c --- /dev/null +++ b/frontend/public/assets/flags/gf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/gg.svg b/frontend/public/assets/flags/gg.svg new file mode 100644 index 0000000..f8216c8 --- /dev/null +++ b/frontend/public/assets/flags/gg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/gh.svg b/frontend/public/assets/flags/gh.svg new file mode 100644 index 0000000..5c3e3e6 --- /dev/null +++ b/frontend/public/assets/flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/gi.svg b/frontend/public/assets/flags/gi.svg new file mode 100644 index 0000000..a5d7570 --- /dev/null +++ b/frontend/public/assets/flags/gi.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gl.svg b/frontend/public/assets/flags/gl.svg new file mode 100644 index 0000000..eb5a52e --- /dev/null +++ b/frontend/public/assets/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/gm.svg b/frontend/public/assets/flags/gm.svg new file mode 100644 index 0000000..8fe9d66 --- /dev/null +++ b/frontend/public/assets/flags/gm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gn.svg b/frontend/public/assets/flags/gn.svg new file mode 100644 index 0000000..40d6ad4 --- /dev/null +++ b/frontend/public/assets/flags/gn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/gp.svg b/frontend/public/assets/flags/gp.svg new file mode 100644 index 0000000..ee55c4b --- /dev/null +++ b/frontend/public/assets/flags/gp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/gq.svg b/frontend/public/assets/flags/gq.svg new file mode 100644 index 0000000..64c8eb2 --- /dev/null +++ b/frontend/public/assets/flags/gq.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gr.svg b/frontend/public/assets/flags/gr.svg new file mode 100644 index 0000000..599741e --- /dev/null +++ b/frontend/public/assets/flags/gr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gs.svg b/frontend/public/assets/flags/gs.svg new file mode 100644 index 0000000..29db9b9 --- /dev/null +++ b/frontend/public/assets/flags/gs.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gt.svg b/frontend/public/assets/flags/gt.svg new file mode 100644 index 0000000..7df9df5 --- /dev/null +++ b/frontend/public/assets/flags/gt.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gu.svg b/frontend/public/assets/flags/gu.svg new file mode 100644 index 0000000..3b95219 --- /dev/null +++ b/frontend/public/assets/flags/gu.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gw.svg b/frontend/public/assets/flags/gw.svg new file mode 100644 index 0000000..d470bac --- /dev/null +++ b/frontend/public/assets/flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/gy.svg b/frontend/public/assets/flags/gy.svg new file mode 100644 index 0000000..569fb56 --- /dev/null +++ b/frontend/public/assets/flags/gy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/hk.svg b/frontend/public/assets/flags/hk.svg new file mode 100644 index 0000000..4fd55bc --- /dev/null +++ b/frontend/public/assets/flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/hm.svg b/frontend/public/assets/flags/hm.svg new file mode 100644 index 0000000..815c482 --- /dev/null +++ b/frontend/public/assets/flags/hm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/hn.svg b/frontend/public/assets/flags/hn.svg new file mode 100644 index 0000000..11fde67 --- /dev/null +++ b/frontend/public/assets/flags/hn.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/hr.svg b/frontend/public/assets/flags/hr.svg new file mode 100644 index 0000000..dde825c --- /dev/null +++ b/frontend/public/assets/flags/hr.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ht.svg b/frontend/public/assets/flags/ht.svg new file mode 100644 index 0000000..8e8efc4 --- /dev/null +++ b/frontend/public/assets/flags/ht.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/hu.svg b/frontend/public/assets/flags/hu.svg new file mode 100644 index 0000000..24fbfb9 --- /dev/null +++ b/frontend/public/assets/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/ic.svg b/frontend/public/assets/flags/ic.svg new file mode 100644 index 0000000..81e6ee2 --- /dev/null +++ b/frontend/public/assets/flags/ic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/id.svg b/frontend/public/assets/flags/id.svg new file mode 100644 index 0000000..3b7c8fc --- /dev/null +++ b/frontend/public/assets/flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/ie.svg b/frontend/public/assets/flags/ie.svg new file mode 100644 index 0000000..049be14 --- /dev/null +++ b/frontend/public/assets/flags/ie.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/il.svg b/frontend/public/assets/flags/il.svg new file mode 100644 index 0000000..f43be7e --- /dev/null +++ b/frontend/public/assets/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/im.svg b/frontend/public/assets/flags/im.svg new file mode 100644 index 0000000..fe6a59a --- /dev/null +++ b/frontend/public/assets/flags/im.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/in.svg b/frontend/public/assets/flags/in.svg new file mode 100644 index 0000000..bc47d74 --- /dev/null +++ b/frontend/public/assets/flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/io.svg b/frontend/public/assets/flags/io.svg new file mode 100644 index 0000000..3058f7d --- /dev/null +++ b/frontend/public/assets/flags/io.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/iq.svg b/frontend/public/assets/flags/iq.svg new file mode 100644 index 0000000..8044514 --- /dev/null +++ b/frontend/public/assets/flags/iq.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/ir.svg b/frontend/public/assets/flags/ir.svg new file mode 100644 index 0000000..8c6d516 --- /dev/null +++ b/frontend/public/assets/flags/ir.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/is.svg b/frontend/public/assets/flags/is.svg new file mode 100644 index 0000000..a6588af --- /dev/null +++ b/frontend/public/assets/flags/is.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/it.svg b/frontend/public/assets/flags/it.svg new file mode 100644 index 0000000..20a8bfd --- /dev/null +++ b/frontend/public/assets/flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/je.svg b/frontend/public/assets/flags/je.svg new file mode 100644 index 0000000..70a8754 --- /dev/null +++ b/frontend/public/assets/flags/je.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/jm.svg b/frontend/public/assets/flags/jm.svg new file mode 100644 index 0000000..269df03 --- /dev/null +++ b/frontend/public/assets/flags/jm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/jo.svg b/frontend/public/assets/flags/jo.svg new file mode 100644 index 0000000..d6f927d --- /dev/null +++ b/frontend/public/assets/flags/jo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/jp.svg b/frontend/public/assets/flags/jp.svg new file mode 100644 index 0000000..cc1c181 --- /dev/null +++ b/frontend/public/assets/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ke.svg b/frontend/public/assets/flags/ke.svg new file mode 100644 index 0000000..3a67ca3 --- /dev/null +++ b/frontend/public/assets/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kg.svg b/frontend/public/assets/flags/kg.svg new file mode 100644 index 0000000..e26db95 --- /dev/null +++ b/frontend/public/assets/flags/kg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/kh.svg b/frontend/public/assets/flags/kh.svg new file mode 100644 index 0000000..a7d52f2 --- /dev/null +++ b/frontend/public/assets/flags/kh.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ki.svg b/frontend/public/assets/flags/ki.svg new file mode 100644 index 0000000..fda03f3 --- /dev/null +++ b/frontend/public/assets/flags/ki.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/km.svg b/frontend/public/assets/flags/km.svg new file mode 100644 index 0000000..414d65e --- /dev/null +++ b/frontend/public/assets/flags/km.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kn.svg b/frontend/public/assets/flags/kn.svg new file mode 100644 index 0000000..47fe64d --- /dev/null +++ b/frontend/public/assets/flags/kn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kp.svg b/frontend/public/assets/flags/kp.svg new file mode 100644 index 0000000..ad1b713 --- /dev/null +++ b/frontend/public/assets/flags/kp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kr.svg b/frontend/public/assets/flags/kr.svg new file mode 100644 index 0000000..6947eab --- /dev/null +++ b/frontend/public/assets/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kw.svg b/frontend/public/assets/flags/kw.svg new file mode 100644 index 0000000..3dd89e9 --- /dev/null +++ b/frontend/public/assets/flags/kw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ky.svg b/frontend/public/assets/flags/ky.svg new file mode 100644 index 0000000..aeaa7e0 --- /dev/null +++ b/frontend/public/assets/flags/ky.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/kz.svg b/frontend/public/assets/flags/kz.svg new file mode 100644 index 0000000..2fac45b --- /dev/null +++ b/frontend/public/assets/flags/kz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/la.svg b/frontend/public/assets/flags/la.svg new file mode 100644 index 0000000..6aea6b7 --- /dev/null +++ b/frontend/public/assets/flags/la.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lb.svg b/frontend/public/assets/flags/lb.svg new file mode 100644 index 0000000..bde2581 --- /dev/null +++ b/frontend/public/assets/flags/lb.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lc.svg b/frontend/public/assets/flags/lc.svg new file mode 100644 index 0000000..bb25654 --- /dev/null +++ b/frontend/public/assets/flags/lc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/li.svg b/frontend/public/assets/flags/li.svg new file mode 100644 index 0000000..7a4d183 --- /dev/null +++ b/frontend/public/assets/flags/li.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lk.svg b/frontend/public/assets/flags/lk.svg new file mode 100644 index 0000000..cbd660a --- /dev/null +++ b/frontend/public/assets/flags/lk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/lr.svg b/frontend/public/assets/flags/lr.svg new file mode 100644 index 0000000..e482ab9 --- /dev/null +++ b/frontend/public/assets/flags/lr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ls.svg b/frontend/public/assets/flags/ls.svg new file mode 100644 index 0000000..a7c01a9 --- /dev/null +++ b/frontend/public/assets/flags/ls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/lt.svg b/frontend/public/assets/flags/lt.svg new file mode 100644 index 0000000..90ec5d2 --- /dev/null +++ b/frontend/public/assets/flags/lt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/lu.svg b/frontend/public/assets/flags/lu.svg new file mode 100644 index 0000000..cc12206 --- /dev/null +++ b/frontend/public/assets/flags/lu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/lv.svg b/frontend/public/assets/flags/lv.svg new file mode 100644 index 0000000..f6decec --- /dev/null +++ b/frontend/public/assets/flags/lv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/ly.svg b/frontend/public/assets/flags/ly.svg new file mode 100644 index 0000000..1eaa51e --- /dev/null +++ b/frontend/public/assets/flags/ly.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ma.svg b/frontend/public/assets/flags/ma.svg new file mode 100644 index 0000000..7ce56ef --- /dev/null +++ b/frontend/public/assets/flags/ma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/mc.svg b/frontend/public/assets/flags/mc.svg new file mode 100644 index 0000000..9cb6c9e --- /dev/null +++ b/frontend/public/assets/flags/mc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/md.svg b/frontend/public/assets/flags/md.svg new file mode 100644 index 0000000..e9ba506 --- /dev/null +++ b/frontend/public/assets/flags/md.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/me.svg b/frontend/public/assets/flags/me.svg new file mode 100644 index 0000000..297888c --- /dev/null +++ b/frontend/public/assets/flags/me.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mf.svg b/frontend/public/assets/flags/mf.svg new file mode 100644 index 0000000..6305edc --- /dev/null +++ b/frontend/public/assets/flags/mf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/mg.svg b/frontend/public/assets/flags/mg.svg new file mode 100644 index 0000000..5fa2d24 --- /dev/null +++ b/frontend/public/assets/flags/mg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/mh.svg b/frontend/public/assets/flags/mh.svg new file mode 100644 index 0000000..7b9f490 --- /dev/null +++ b/frontend/public/assets/flags/mh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/mk.svg b/frontend/public/assets/flags/mk.svg new file mode 100644 index 0000000..4f5cae7 --- /dev/null +++ b/frontend/public/assets/flags/mk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ml.svg b/frontend/public/assets/flags/ml.svg new file mode 100644 index 0000000..6f6b716 --- /dev/null +++ b/frontend/public/assets/flags/ml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/mm.svg b/frontend/public/assets/flags/mm.svg new file mode 100644 index 0000000..42b4dee --- /dev/null +++ b/frontend/public/assets/flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mn.svg b/frontend/public/assets/flags/mn.svg new file mode 100644 index 0000000..6a38a71 --- /dev/null +++ b/frontend/public/assets/flags/mn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mo.svg b/frontend/public/assets/flags/mo.svg new file mode 100644 index 0000000..f638b6c --- /dev/null +++ b/frontend/public/assets/flags/mo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/mp.svg b/frontend/public/assets/flags/mp.svg new file mode 100644 index 0000000..26bfa22 --- /dev/null +++ b/frontend/public/assets/flags/mp.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mq.svg b/frontend/public/assets/flags/mq.svg new file mode 100644 index 0000000..b221951 --- /dev/null +++ b/frontend/public/assets/flags/mq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/mr.svg b/frontend/public/assets/flags/mr.svg new file mode 100644 index 0000000..d859972 --- /dev/null +++ b/frontend/public/assets/flags/mr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ms.svg b/frontend/public/assets/flags/ms.svg new file mode 100644 index 0000000..4367505 --- /dev/null +++ b/frontend/public/assets/flags/ms.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mt.svg b/frontend/public/assets/flags/mt.svg new file mode 100644 index 0000000..5d5d7c8 --- /dev/null +++ b/frontend/public/assets/flags/mt.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mu.svg b/frontend/public/assets/flags/mu.svg new file mode 100644 index 0000000..82d7a3b --- /dev/null +++ b/frontend/public/assets/flags/mu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/mv.svg b/frontend/public/assets/flags/mv.svg new file mode 100644 index 0000000..10450f9 --- /dev/null +++ b/frontend/public/assets/flags/mv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/mw.svg b/frontend/public/assets/flags/mw.svg new file mode 100644 index 0000000..137ff87 --- /dev/null +++ b/frontend/public/assets/flags/mw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/mx.svg b/frontend/public/assets/flags/mx.svg new file mode 100644 index 0000000..e3ec2bc --- /dev/null +++ b/frontend/public/assets/flags/mx.svg @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/my.svg b/frontend/public/assets/flags/my.svg new file mode 100644 index 0000000..115f864 --- /dev/null +++ b/frontend/public/assets/flags/my.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/mz.svg b/frontend/public/assets/flags/mz.svg new file mode 100644 index 0000000..0f94c3a --- /dev/null +++ b/frontend/public/assets/flags/mz.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/na.svg b/frontend/public/assets/flags/na.svg new file mode 100644 index 0000000..35b9f78 --- /dev/null +++ b/frontend/public/assets/flags/na.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nc.svg b/frontend/public/assets/flags/nc.svg new file mode 100644 index 0000000..fa15551 --- /dev/null +++ b/frontend/public/assets/flags/nc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ne.svg b/frontend/public/assets/flags/ne.svg new file mode 100644 index 0000000..39a82b8 --- /dev/null +++ b/frontend/public/assets/flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/nf.svg b/frontend/public/assets/flags/nf.svg new file mode 100644 index 0000000..fd61b25 --- /dev/null +++ b/frontend/public/assets/flags/nf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/ng.svg b/frontend/public/assets/flags/ng.svg new file mode 100644 index 0000000..81eb35f --- /dev/null +++ b/frontend/public/assets/flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ni.svg b/frontend/public/assets/flags/ni.svg new file mode 100644 index 0000000..e4861f5 --- /dev/null +++ b/frontend/public/assets/flags/ni.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nl.svg b/frontend/public/assets/flags/nl.svg new file mode 100644 index 0000000..e90f5b0 --- /dev/null +++ b/frontend/public/assets/flags/nl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/no.svg b/frontend/public/assets/flags/no.svg new file mode 100644 index 0000000..a5f2a15 --- /dev/null +++ b/frontend/public/assets/flags/no.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/np.svg b/frontend/public/assets/flags/np.svg new file mode 100644 index 0000000..6242856 --- /dev/null +++ b/frontend/public/assets/flags/np.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nr.svg b/frontend/public/assets/flags/nr.svg new file mode 100644 index 0000000..ff394c4 --- /dev/null +++ b/frontend/public/assets/flags/nr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/nu.svg b/frontend/public/assets/flags/nu.svg new file mode 100644 index 0000000..4067baf --- /dev/null +++ b/frontend/public/assets/flags/nu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/nz.svg b/frontend/public/assets/flags/nz.svg new file mode 100644 index 0000000..935d8a7 --- /dev/null +++ b/frontend/public/assets/flags/nz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/om.svg b/frontend/public/assets/flags/om.svg new file mode 100644 index 0000000..4f1461a --- /dev/null +++ b/frontend/public/assets/flags/om.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pa.svg b/frontend/public/assets/flags/pa.svg new file mode 100644 index 0000000..9ab733f --- /dev/null +++ b/frontend/public/assets/flags/pa.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/pc.svg b/frontend/public/assets/flags/pc.svg new file mode 100644 index 0000000..5202d6d --- /dev/null +++ b/frontend/public/assets/flags/pc.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pe.svg b/frontend/public/assets/flags/pe.svg new file mode 100644 index 0000000..33e6cfd --- /dev/null +++ b/frontend/public/assets/flags/pe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/pf.svg b/frontend/public/assets/flags/pf.svg new file mode 100644 index 0000000..bea0354 --- /dev/null +++ b/frontend/public/assets/flags/pf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pg.svg b/frontend/public/assets/flags/pg.svg new file mode 100644 index 0000000..7b7e77a --- /dev/null +++ b/frontend/public/assets/flags/pg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/ph.svg b/frontend/public/assets/flags/ph.svg new file mode 100644 index 0000000..b910e24 --- /dev/null +++ b/frontend/public/assets/flags/ph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/pk.svg b/frontend/public/assets/flags/pk.svg new file mode 100644 index 0000000..4ddc19f --- /dev/null +++ b/frontend/public/assets/flags/pk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pl.svg b/frontend/public/assets/flags/pl.svg new file mode 100644 index 0000000..42d2b0c --- /dev/null +++ b/frontend/public/assets/flags/pl.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/pm.svg b/frontend/public/assets/flags/pm.svg new file mode 100644 index 0000000..19a9330 --- /dev/null +++ b/frontend/public/assets/flags/pm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/pn.svg b/frontend/public/assets/flags/pn.svg new file mode 100644 index 0000000..209ea71 --- /dev/null +++ b/frontend/public/assets/flags/pn.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pr.svg b/frontend/public/assets/flags/pr.svg new file mode 100644 index 0000000..ec51831 --- /dev/null +++ b/frontend/public/assets/flags/pr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ps.svg b/frontend/public/assets/flags/ps.svg new file mode 100644 index 0000000..362d435 --- /dev/null +++ b/frontend/public/assets/flags/ps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/pt.svg b/frontend/public/assets/flags/pt.svg new file mode 100644 index 0000000..2767cd4 --- /dev/null +++ b/frontend/public/assets/flags/pt.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/pw.svg b/frontend/public/assets/flags/pw.svg new file mode 100644 index 0000000..9f89c5f --- /dev/null +++ b/frontend/public/assets/flags/pw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/py.svg b/frontend/public/assets/flags/py.svg new file mode 100644 index 0000000..abccd87 --- /dev/null +++ b/frontend/public/assets/flags/py.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/qa.svg b/frontend/public/assets/flags/qa.svg new file mode 100644 index 0000000..901f3fa --- /dev/null +++ b/frontend/public/assets/flags/qa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/re.svg b/frontend/public/assets/flags/re.svg new file mode 100644 index 0000000..64e788e --- /dev/null +++ b/frontend/public/assets/flags/re.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ro.svg b/frontend/public/assets/flags/ro.svg new file mode 100644 index 0000000..fda0f7b --- /dev/null +++ b/frontend/public/assets/flags/ro.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/rs.svg b/frontend/public/assets/flags/rs.svg new file mode 100644 index 0000000..6d4f74d --- /dev/null +++ b/frontend/public/assets/flags/rs.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ru.svg b/frontend/public/assets/flags/ru.svg new file mode 100644 index 0000000..cf24301 --- /dev/null +++ b/frontend/public/assets/flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/rw.svg b/frontend/public/assets/flags/rw.svg new file mode 100644 index 0000000..06e26ae --- /dev/null +++ b/frontend/public/assets/flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sa.svg b/frontend/public/assets/flags/sa.svg new file mode 100644 index 0000000..596cf48 --- /dev/null +++ b/frontend/public/assets/flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sb.svg b/frontend/public/assets/flags/sb.svg new file mode 100644 index 0000000..6066f94 --- /dev/null +++ b/frontend/public/assets/flags/sb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sc.svg b/frontend/public/assets/flags/sc.svg new file mode 100644 index 0000000..9a46b36 --- /dev/null +++ b/frontend/public/assets/flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/sd.svg b/frontend/public/assets/flags/sd.svg new file mode 100644 index 0000000..12818b4 --- /dev/null +++ b/frontend/public/assets/flags/sd.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/se.svg b/frontend/public/assets/flags/se.svg new file mode 100644 index 0000000..8ba745a --- /dev/null +++ b/frontend/public/assets/flags/se.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/sg.svg b/frontend/public/assets/flags/sg.svg new file mode 100644 index 0000000..c4dd4ac --- /dev/null +++ b/frontend/public/assets/flags/sg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh-ac.svg b/frontend/public/assets/flags/sh-ac.svg new file mode 100644 index 0000000..c43b301 --- /dev/null +++ b/frontend/public/assets/flags/sh-ac.svg @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh-hl.svg b/frontend/public/assets/flags/sh-hl.svg new file mode 100644 index 0000000..2150bf6 --- /dev/null +++ b/frontend/public/assets/flags/sh-hl.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh-ta.svg b/frontend/public/assets/flags/sh-ta.svg new file mode 100644 index 0000000..ba39063 --- /dev/null +++ b/frontend/public/assets/flags/sh-ta.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sh.svg b/frontend/public/assets/flags/sh.svg new file mode 100644 index 0000000..7aba0ae --- /dev/null +++ b/frontend/public/assets/flags/sh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/si.svg b/frontend/public/assets/flags/si.svg new file mode 100644 index 0000000..1bbdd94 --- /dev/null +++ b/frontend/public/assets/flags/si.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sj.svg b/frontend/public/assets/flags/sj.svg new file mode 100644 index 0000000..bb2799c --- /dev/null +++ b/frontend/public/assets/flags/sj.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/sk.svg b/frontend/public/assets/flags/sk.svg new file mode 100644 index 0000000..676018e --- /dev/null +++ b/frontend/public/assets/flags/sk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/sl.svg b/frontend/public/assets/flags/sl.svg new file mode 100644 index 0000000..a07baf7 --- /dev/null +++ b/frontend/public/assets/flags/sl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/sm.svg b/frontend/public/assets/flags/sm.svg new file mode 100644 index 0000000..e41d2f7 --- /dev/null +++ b/frontend/public/assets/flags/sm.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sn.svg b/frontend/public/assets/flags/sn.svg new file mode 100644 index 0000000..7c0673d --- /dev/null +++ b/frontend/public/assets/flags/sn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/so.svg b/frontend/public/assets/flags/so.svg new file mode 100644 index 0000000..a581ac6 --- /dev/null +++ b/frontend/public/assets/flags/so.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sr.svg b/frontend/public/assets/flags/sr.svg new file mode 100644 index 0000000..5e71c40 --- /dev/null +++ b/frontend/public/assets/flags/sr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ss.svg b/frontend/public/assets/flags/ss.svg new file mode 100644 index 0000000..b257aa0 --- /dev/null +++ b/frontend/public/assets/flags/ss.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/st.svg b/frontend/public/assets/flags/st.svg new file mode 100644 index 0000000..1294bcb --- /dev/null +++ b/frontend/public/assets/flags/st.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sv.svg b/frontend/public/assets/flags/sv.svg new file mode 100644 index 0000000..cbc674a --- /dev/null +++ b/frontend/public/assets/flags/sv.svg @@ -0,0 +1,593 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sx.svg b/frontend/public/assets/flags/sx.svg new file mode 100644 index 0000000..ac78561 --- /dev/null +++ b/frontend/public/assets/flags/sx.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/sy.svg b/frontend/public/assets/flags/sy.svg new file mode 100644 index 0000000..97c05cf --- /dev/null +++ b/frontend/public/assets/flags/sy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/sz.svg b/frontend/public/assets/flags/sz.svg new file mode 100644 index 0000000..eb538e4 --- /dev/null +++ b/frontend/public/assets/flags/sz.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tc.svg b/frontend/public/assets/flags/tc.svg new file mode 100644 index 0000000..1258971 --- /dev/null +++ b/frontend/public/assets/flags/tc.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/td.svg b/frontend/public/assets/flags/td.svg new file mode 100644 index 0000000..fa3bd92 --- /dev/null +++ b/frontend/public/assets/flags/td.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/tf.svg b/frontend/public/assets/flags/tf.svg new file mode 100644 index 0000000..fba2335 --- /dev/null +++ b/frontend/public/assets/flags/tf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tg.svg b/frontend/public/assets/flags/tg.svg new file mode 100644 index 0000000..9d6ea6c --- /dev/null +++ b/frontend/public/assets/flags/tg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/th.svg b/frontend/public/assets/flags/th.svg new file mode 100644 index 0000000..1e93a61 --- /dev/null +++ b/frontend/public/assets/flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/tj.svg b/frontend/public/assets/flags/tj.svg new file mode 100644 index 0000000..f8c9a03 --- /dev/null +++ b/frontend/public/assets/flags/tj.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tk.svg b/frontend/public/assets/flags/tk.svg new file mode 100644 index 0000000..05d3e86 --- /dev/null +++ b/frontend/public/assets/flags/tk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/tl.svg b/frontend/public/assets/flags/tl.svg new file mode 100644 index 0000000..3d0701a --- /dev/null +++ b/frontend/public/assets/flags/tl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tm.svg b/frontend/public/assets/flags/tm.svg new file mode 100644 index 0000000..4154ed7 --- /dev/null +++ b/frontend/public/assets/flags/tm.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tn.svg b/frontend/public/assets/flags/tn.svg new file mode 100644 index 0000000..5735c19 --- /dev/null +++ b/frontend/public/assets/flags/tn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/to.svg b/frontend/public/assets/flags/to.svg new file mode 100644 index 0000000..d072337 --- /dev/null +++ b/frontend/public/assets/flags/to.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/assets/flags/tr.svg b/frontend/public/assets/flags/tr.svg new file mode 100644 index 0000000..b96da21 --- /dev/null +++ b/frontend/public/assets/flags/tr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/tt.svg b/frontend/public/assets/flags/tt.svg new file mode 100644 index 0000000..bc24938 --- /dev/null +++ b/frontend/public/assets/flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/tv.svg b/frontend/public/assets/flags/tv.svg new file mode 100644 index 0000000..675210e --- /dev/null +++ b/frontend/public/assets/flags/tv.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/tw.svg b/frontend/public/assets/flags/tw.svg new file mode 100644 index 0000000..57fd98b --- /dev/null +++ b/frontend/public/assets/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/tz.svg b/frontend/public/assets/flags/tz.svg new file mode 100644 index 0000000..a2cfbca --- /dev/null +++ b/frontend/public/assets/flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/ua.svg b/frontend/public/assets/flags/ua.svg new file mode 100644 index 0000000..03daa19 --- /dev/null +++ b/frontend/public/assets/flags/ua.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/assets/flags/ug.svg b/frontend/public/assets/flags/ug.svg new file mode 100644 index 0000000..520eee5 --- /dev/null +++ b/frontend/public/assets/flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/um.svg b/frontend/public/assets/flags/um.svg new file mode 100644 index 0000000..9e9edda --- /dev/null +++ b/frontend/public/assets/flags/um.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/un.svg b/frontend/public/assets/flags/un.svg new file mode 100644 index 0000000..632bbb4 --- /dev/null +++ b/frontend/public/assets/flags/un.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/us.svg b/frontend/public/assets/flags/us.svg new file mode 100644 index 0000000..9cfd0c9 --- /dev/null +++ b/frontend/public/assets/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/assets/flags/uy.svg b/frontend/public/assets/flags/uy.svg new file mode 100644 index 0000000..62c36f8 --- /dev/null +++ b/frontend/public/assets/flags/uy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/uz.svg b/frontend/public/assets/flags/uz.svg new file mode 100644 index 0000000..0ccca1b --- /dev/null +++ b/frontend/public/assets/flags/uz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/va.svg b/frontend/public/assets/flags/va.svg new file mode 100644 index 0000000..3e297d6 --- /dev/null +++ b/frontend/public/assets/flags/va.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vc.svg b/frontend/public/assets/flags/vc.svg new file mode 100644 index 0000000..f26c2d8 --- /dev/null +++ b/frontend/public/assets/flags/vc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/flags/ve.svg b/frontend/public/assets/flags/ve.svg new file mode 100644 index 0000000..314e7f5 --- /dev/null +++ b/frontend/public/assets/flags/ve.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vg.svg b/frontend/public/assets/flags/vg.svg new file mode 100644 index 0000000..ac90088 --- /dev/null +++ b/frontend/public/assets/flags/vg.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vi.svg b/frontend/public/assets/flags/vi.svg new file mode 100644 index 0000000..d88d68f --- /dev/null +++ b/frontend/public/assets/flags/vi.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vn.svg b/frontend/public/assets/flags/vn.svg new file mode 100644 index 0000000..7e4bac8 --- /dev/null +++ b/frontend/public/assets/flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/assets/flags/vu.svg b/frontend/public/assets/flags/vu.svg new file mode 100644 index 0000000..326d29e --- /dev/null +++ b/frontend/public/assets/flags/vu.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/wf.svg b/frontend/public/assets/flags/wf.svg new file mode 100644 index 0000000..054c57d --- /dev/null +++ b/frontend/public/assets/flags/wf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/ws.svg b/frontend/public/assets/flags/ws.svg new file mode 100644 index 0000000..0e758a7 --- /dev/null +++ b/frontend/public/assets/flags/ws.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/xk.svg b/frontend/public/assets/flags/xk.svg new file mode 100644 index 0000000..0e8958d --- /dev/null +++ b/frontend/public/assets/flags/xk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/xx.svg b/frontend/public/assets/flags/xx.svg new file mode 100644 index 0000000..9333be3 --- /dev/null +++ b/frontend/public/assets/flags/xx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/assets/flags/ye.svg b/frontend/public/assets/flags/ye.svg new file mode 100644 index 0000000..1c9e6d6 --- /dev/null +++ b/frontend/public/assets/flags/ye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/flags/yt.svg b/frontend/public/assets/flags/yt.svg new file mode 100644 index 0000000..e7776b3 --- /dev/null +++ b/frontend/public/assets/flags/yt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/flags/za.svg b/frontend/public/assets/flags/za.svg new file mode 100644 index 0000000..d563adb --- /dev/null +++ b/frontend/public/assets/flags/za.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/zm.svg b/frontend/public/assets/flags/zm.svg new file mode 100644 index 0000000..360f37a --- /dev/null +++ b/frontend/public/assets/flags/zm.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/flags/zw.svg b/frontend/public/assets/flags/zw.svg new file mode 100644 index 0000000..93aac4f --- /dev/null +++ b/frontend/public/assets/flags/zw.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bff17ac..bb1ae62 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings"; +import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; -import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, InstallFFmpegWithBrew } from "../wailsjs/go/main/App"; +import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App"; import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { TitleBar } from "@/components/TitleBar"; @@ -35,6 +35,7 @@ import { useAvailability } from "@/hooks/useAvailability"; import { ensureApiStatusCheckStarted } from "@/lib/api-status"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; +import { buildPlaylistFolderName } from "@/lib/playlist"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; function extractSpotifyEntityFromURL(url: string): { @@ -103,6 +104,25 @@ function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { } return deduped; } +function sortHistoryItems(items: HistoryItem[]): HistoryItem[] { + return [...items].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); +} +function normalizeHistoryItems(items: HistoryItem[]): HistoryItem[] { + return dedupeHistoryItems(sortHistoryItems(items)).slice(0, MAX_HISTORY); +} +function parseStoredHistory(value: string | null): HistoryItem[] { + if (!value) { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } + catch (err) { + console.error("Failed to parse stored history:", err); + return []; + } +} function App() { const [currentPage, setCurrentPage] = useState("main"); const [spotifyUrl, setSpotifyUrl] = useState(""); @@ -133,7 +153,6 @@ function App() { const downloadQueue = useDownloadQueueDialog(); const downloadProgress = useDownloadProgress(); const [isFFmpegInstalled, setIsFFmpegInstalled] = useState(null); - const [brewPath, setBrewPath] = useState(""); const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0); const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState(""); @@ -161,8 +180,6 @@ function App() { try { const installed = await CheckFFmpegInstalled(); setIsFFmpegInstalled(installed); - const brew = await GetBrewPath(); - setBrewPath(brew); } catch (err) { console.error("Failed to check FFmpeg:", err); @@ -181,7 +198,7 @@ function App() { mediaQuery.addEventListener("change", handleChange); checkForUpdates(); ensureApiStatusCheckStarted(); - loadHistory(); + void loadHistory(); const handleScroll = () => { setShowScrollTop(window.scrollY > 300); }; @@ -191,17 +208,6 @@ function App() { window.removeEventListener("scroll", handleScroll); }; }, []); - const handleEnableSpotFetchApi = async () => { - try { - await updateSettings({ useSpotFetchAPI: true }); - metadata.setShowApiModal(false); - toast.success("SpotFetch API enabled! You can now try fetching again."); - } - catch (err) { - console.error("Failed to enable SpotFetch API:", err); - toast.error("Failed to update settings"); - } - }; const scrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: "smooth" }); }, []); @@ -231,20 +237,30 @@ function App() { console.error("Failed to check for updates:", err); } }; - const loadHistory = () => { + const persistRecentHistory = useCallback(async (history: HistoryItem[]) => { try { - const saved = localStorage.getItem(HISTORY_KEY); - if (saved) { - const deduped = dedupeHistoryItems(JSON.parse(saved)); - setFetchHistory(deduped); - localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped)); - } + await SaveRecentFetches(JSON.stringify(history)); + } + catch (err) { + console.error("Failed to save recent fetches:", err); + } + }, []); + const loadHistory = useCallback(async () => { + try { + const saved = parseStoredHistory(localStorage.getItem(HISTORY_KEY)); + const persisted = parseStoredHistory(await GetRecentFetches()); + const normalized = normalizeHistoryItems([...persisted, ...saved]); + setFetchHistory(normalized); + await persistRecentHistory(normalized); } catch (err) { console.error("Failed to load history:", err); } - }; - const handleInstallFFmpeg = async (useBrew: boolean = false) => { + finally { + localStorage.removeItem(HISTORY_KEY); + } + }, [persistRecentHistory]); + const handleInstallFFmpeg = async () => { setIsInstallingFFmpeg(true); setFfmpegInstallProgress(0); setFfmpegInstallStatus("starting"); @@ -261,11 +277,11 @@ function App() { EventsOn("ffmpeg:status", (status: string) => { setFfmpegInstallStatus(status); }); - const response = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg(); + const response = await DownloadFFmpeg(); EventsOff("ffmpeg:progress"); EventsOff("ffmpeg:status"); if (response.success) { - toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "FFmpeg installed successfully!"); + toast.success("FFmpeg installed successfully!"); setIsFFmpegInstalled(true); } else { @@ -282,14 +298,6 @@ function App() { setFfmpegInstallStatus(""); } }; - const saveHistory = (history: HistoryItem[]) => { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); - } - catch (err) { - console.error("Failed to save history:", err); - } - }; const addToHistory = (item: Omit) => { setFetchHistory((prev) => { const normalizedUrl = normalizeHistoryURL(item.url); @@ -301,15 +309,17 @@ function App() { id: crypto.randomUUID(), timestamp: Date.now(), }; - const updated = [newItem, ...filtered].slice(0, MAX_HISTORY); - saveHistory(updated); + const updated = normalizeHistoryItems([newItem, ...filtered]); + void persistRecentHistory(updated); return updated; }); }; const removeFromHistory = (id: string) => { setFetchHistory((prev) => { + if (!prev.some((h) => h.id === id)) + return prev; const updated = prev.filter((h) => h.id !== id); - saveHistory(updated); + void persistRecentHistory(updated); return updated; }); }; @@ -413,11 +423,16 @@ function App() { if ("track" in metadata.metadata) { const { track } = metadata.metadata; const trackId = track.spotify_id || ""; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const artistUrl = await metadata.handleArtistClick(artist); + if (artistUrl) { + setSpotifyUrl(artistUrl); + } + }} onBack={metadata.resetMetadata}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); @@ -433,7 +448,9 @@ function App() { } if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const settings = getSettings(); + const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); @@ -449,7 +466,7 @@ function App() { } if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); @@ -609,7 +626,34 @@ function App() { - + + + + Fetch Failed + + + Metadata fetch failed. Try using a high-quality VPN such as + Surfshark, ExpressVPN, Proton VPN, or a similar service. + + + Choose a location that is not blocked by Spotify or the + related service, such as the USA, UK, Germany, Netherlands, + or Singapore. + + + If you are already using a VPN, try switching to another + server and fetch again. + + + + + + + + + { }}> @@ -617,13 +661,9 @@ function App() { FFmpeg Required - {brewPath ? (<> - FFmpeg is essential for SpotiFLAC to function properly. - Homebrew detected. Recommended: brew install ffmpeg - ) : (<> - FFmpeg is essential for SpotiFLAC to function properly. - This setup will download about 100-200MB of data. - )} + SpotiFLAC checks your system for FFmpeg and FFprobe first. + If they are not available, the required binaries will be downloaded from GitHub. + This setup downloads about 30-40MB of data. @@ -651,34 +691,13 @@ function App() { )} )} - + {!isInstallingFFmpeg && ()} - {brewPath ? () : ()} - - - - - - - - SpotFetch API Recommended - - Direct fetch failed. This usually happens when your country is blocked by Spotify or your IP is restricted. Would you like to enable the SpotFetch API to bypass this? - - - - - + diff --git a/frontend/src/assets/icons/amazon-music.png b/frontend/src/assets/icons/amazon-music.png deleted file mode 100644 index 92d42cfd1ed6948fe4c1a681e70a4489cab5ada0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1411 zcmV-}1$_F6P)C0000FP)t-sKIZNz z)!sDS<^co+ABT*0fL&w&00l2eL_t(|ob6cclG`u{1futU<|ei!?Jmg9U1$2IA~}CZ zz%C1RIyKw2ZQHhO+qP}nwr$(CZQK59i}Mh6ea{8hlk4dc?2;V{AU$Ts!6$|nUuXER z${}VXCeWi{ByZTvTVE3eBam8wsE(W^ZWYMIU^1dPYDXms1p@v^#iD3+$ubBD)uI%D zf&zAu08s?@b&<|6Mp9tt$Uup*+JSyjG1MNMDK&_M4vQsjEKBQ zyh&Gy!TfnIXj5sG1wr{mMP-(XkHpUTxl^dnQsQOd67;+){KO@tsy|7+g;QcvQM^f# z#953)CbJ@)5-d3{%7st5SxT`EyIFot1qu}F5&c^WQQkgLs8f*ZSebi=O*V3{0In36 z63s&d#90g{Wj!6GAR;7G7j?>v*2`O4Q zC+8;=mXz%CY`?}M_WD9tnBDeQ%760vguOC86F>%e7Jh=>;SbnSfJK4!Li?o*1O0mW zRL{V2dh``Genq~-HbB2nc?6#b6fe=%;yV)f^EclqRy~6^1QxH+<~J;k_gzcOPtaC2 zAQSL$NI#@NqrYBi0-pcVo+m#mD29jYu!BIbH!kqb;ZawCK3|Ifc*r z^Qy%vda6sELqif5lw+)zRH%qJ z$7i((rV3fExGq^*k=seH(x)N{Hw$E3w1@qIkALOA&cmP2FZma4&o$kD)?2n>-{BBC zT}@JJjO5Nqj1fkVBLh)MZz+bC=GHuS-}7uh1`|ionsE?k1j_pKbRZKxptg{j1Q2|` za?BcWX;eeuBK*8D-CVdszD%hgEh$NViZPe;8YfePC1#OuAX3&^Qu73=%tx6dgX%m& zfmzO)<85v)@Zm4yCXoaMiY$R8^cz#0WiW(e3B-{*?#Urba43+x*kUFJkQ|4-nop?y zjilF&rAiQ{4DZ2d(2t2;GfziunBAeo4_Wp32XwJz$UN>`~lasLWz;P REA0RP002ovPDHLkV1oQ+j8Fgo diff --git a/frontend/src/assets/icons/amzn.png b/frontend/src/assets/icons/amzn.png new file mode 100644 index 0000000000000000000000000000000000000000..e1388461ead8ea6250081d61d2c92f67247c9b1a GIT binary patch literal 56880 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

FLbzm%a*Skx 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/icons/musicbrainz_d.png b/frontend/src/assets/icons/musicbrainz_d.png new file mode 100644 index 0000000000000000000000000000000000000000..7467b95d34502203f79694606cce8ed9c9dd2b23 GIT binary patch literal 28301 zcmV*xKt8{TP)a zQ6AZnA6eJFckNxlk;#m%$YOH0EwJ4y3skGT&HTE?_YI* zCI=u9x`7V$@@Y0YobKv#>ihm*d|xdH0%i%Cfq6h1&>p>IfCiv3dY=T`32zQy0vcdP zKSw^NN1u-|JK#m{%YfYQ)#!5pP>McZjQ(8!MuFkzEf4epJ<<4xO`=a-60SB33&}KjcASe30z_q~5zzQIHp)V>% zjE|uL90lG6HUh5$?=j6WT^+zRz#YJYz*QeR*(F>LqWAI^BgTbBFyWq@XiyR*JO#WA zJPp*CW|&S6U@`Ekz&`@6p78nD$Yac81kw8=!1toJcbF!a4i4ZZ;J1J;L?`{k3yO_D z#%w_Fv3pt~g1?HKK`>VxfQ0ye3_Mc5me}B9%(0YVC-7&$SEA0uoNxe=-~TJ%@!05N z#6S#i7w{i}znjetU;*%NfIoj%b&5#u8m z0e%H|fobLd7DmO2Rg>|Xs%=O2J5#z!VkqBqTw)0s7 z_*QKEF=9*$2$$t6Q5WvKIDmf_wIX7~h%r6T3jFkZqyWOr_uSe1q8Kq^Oga90#IZ3Y z4j=~{h>CwPV#Ju9c=Sx7firOcLTdQHSzlI+7%|QVhNI4$yq~lhz;6KepWAiCh!NvF zpfREto3scZ7YQE3T>P^Df#<9Dn)%DVKd9Bv$OZDJ3r{Mx}3@{*4DY z@SS%l_vL9=EA))NjKHfnPS_dlGA1NhIe@y}dT z^Cbp$chUQUt@OULkJ4z7+@%Z96DL0yG|NOw=kWJ`Na4qSg12h}&JB-ZuDA*#6*qro zAjAQD8R$P72XHHJ+tgfFjHyIW3m8Ap&(Y_$aqQ*o6g!78(+*a~1}Opfr>Oh*0h;)G zI|YV8Y4gie4}5^V?k*D7J&d(@EtrY({pZ9ud!!>L@P9iS2k?JPF=t?mNrn`3i#;Rs zytbRJ7j`hVqZ<<)I~NKr`4#VUe-Kg))YDml;wZ(}zm2#19h~(K;aq(`M*9-=^%ye( zLc{-`fW9+v0PCaEKMwzA2)v;(eeWFL@CzR>xbY}{v5J{-2`uf@C;lfQ2%`l%g>Jcc zJ%{j~{xX#vZ<4s~5$wxuL2qb}9l`WRDq4{`u5iQ;f-iSFAF;xs%S9w$lBJ zT~vAtSjhx-E)9Vn8u2+Hj0U7OK)FC=*SmOKyRmn?fpg8HSgWo@PsjCvX^wvy)$_`y z;{e*DWuN%;pT-FMfU$i&D%3yr;!g5igJ>osav3yJBk=vn{B2=c;3Uy)7r!`0X~XkW z_HD+I`oOghV=lcE&6LHo7}F5#5uxa}PsaiL`swr!V_Y~CkB!jt#vZz!-^R#}Zv0ve zE0w@>Ed0P%&S6R*5rw9qr?Lc|i$C5^@y%~j-M5j%)%RgveII7~@~QdVG0p{YnE&4C zIsh5_l{+Yo0Hztd(INvIk8t#<&Gdh8nCe&wGihUI6YACSC-u}n1tby(0yM)yOEnQx z%GA2{P#8EuWzPnjYwpLn^mg=Y%hX>>j5EQV!19P*{FDx09dP*>{n!{&j-cu@vb~EV z&xHN_a?iL5_w7bE9O)HzJyoavDKAQ#jfQ3?z_bafMJl`Argn4})!iFNT>Sv{$}7Q* zhe)Rpo``Azr*r_1MDOF%f0|(IKp$N%?x6RLJ&Ye5P~p9qw$O~I`hLC*|75iRT|;w{ zXqJs%%2VF(9Nyj!urIw8`}&8m7OhngLyQXwvFXo!ngfuHzu5Sv0gAoj^t`r{?pJm) zvb&dB)x)q2bkmd-uJF{KE~qb*X(pO$f>)(B+C%B>A5l571?SS+NM7|2THB)d?z*tJ z7FYrtI++8Iq3>AL=K`ZTUS{B(19ZNyouREq@y1GOD$jE4Xe?`*8UIHRU7KN|8R7^$ z{3E+5^&H0A`yuv~cjH`n7kWw-Ok-R?7U8d)%mK(3Vq759${r&-yXks*3;pjMq%@RA zGc*jD+t+63u_@7tVc-WfD*N8U>)A_n`|H@(J%O|4S~SPK&{sGYar>zpz|9xtdgeBy za$o5j3Mw*g<~V~NfqwTSi_$PO+%B4rEU-u#;I)mIo`2-co*M7;?{qJ(Yz4N zjOzpE5g)O0^JC+`v*3=KIjD>jIr`>KdVaKx{E>dVvWG5JebbnQFNLO@09F!zsF%{) zKc;$M3(i${<6QeNMy6$UIK1hP#lT8n??eY6oZ^>W*z1}*@JdyNwj8DN#~(7Zsgp{f zq^kP5ZG_u+vlU|C^5b6@*-1Io+l1tDm z=R#lQY()?e{d{Ml1Gpr5KZowZ^NV8F2tBXt;^^~R89Ue?p7@T5HrK;m=@u9ULAgL_ z!_TPf-$-Kp12|XThta+)>?XvROlT8_{p!!|iV0tgF^TZ<6?NJld3F>1TMuD)CWa$} zJn>U~0-Bm8)N~VnxSQgOU!}bDHQcKoB600QXpJI#6JwH~MKyqvI)E6H$i6$U;UGt! z-a_v?2dNdxShjolkG3_lbrLoMyZIQ?#c=4J_M6)M}`pn7mCm2Gb+qEd6&6>*q) z?np#YfN}sW5rb%qb400cjNaFFbKu7tDRqtD*8Gkp>(U9Aif)Xu}!;3PmZY%nZ> z;y9I!&r+4K;%goxdD#Q#3#6MB;~cOQcv~F6d?1dk&H=);H~7vW4*ckShIbyLmam{Y z7N$K-EMIlsG`ze=C0|9GQ8ZqfS~8ODL%*++(u-fCy5kolZ}=Sc#kZhk;<4hhgyMS_ z2M||S&Jsbj#^B~ox?bE)?>l>_4wY3~&v2(nxF7kSzzeA6D_ALu1$T6iTr>lF?b?a@ zfOmK&eIB-99wPv6r@S?8O}v`^M094a1gA zJ*EXzd(jW56sqdUt{tbsY`m;?8 zesDzP{nM(SUk~@IKIPG}nl4;$&mxvSv5Mxa+QR~%kirHMD$k@Ez^6b$epnE5xiLe3XqtH$_nMsP5ZDt!tku6eg~J3~R-e zXz_l*$)M~$jsQLZGP2wG%1(|ww~f-#Av9gbN+-etG?*sgegM^Cjat#e$vG@}d<9FM zSWRki6KEa;6R6-diT-`iXGRqu6qP)@g;Fu@KH3> z9YCz=@G+DJ^Yr|DFP%U6kg=UT)bs$|b;4C$#fp1@FHnwe9d+8*#yyOLLHjN9S@Oh6 zTGn@24veXk%3y3TK548Km7};+ukH`<7cohxm9T!%yHOt2QcS( znM6frXlo~(Pj93Dt^E{whcObiQq-;I*VE$E*EGD6hc{fo&e(K(ZV^j9vxdydTy%k@ z3-6>R1EO!Bq7=z-WHea3-dRQi(k*Z^1l2OtJ#XW6?!emeDz4NAR$nuh*N)Bl|K_gV za{&1xLv%g6jbpEEW9&#Dx@Kc#l4@Oj+8DmT4^)xQ_k3EfZ)f@E*V26bf~W;q3B!V! z3^DkcM^LMsZ~y|CS`^xGp$$`1=w}zzXjYgO1eGF{jn7j(xS7Pox8vUYIgEuX=c34| zHFqO`0LnvodS2V5w)IB$^r{=qa#BiHZ(10B9W}}IJ!)m2J$=dX z3(>XC&~RT)c(1BXXvy96)^?2cg`wc6@8b{l<8>b;7#oIa1ufy8h|11JBaY6F3FsvO4+o6_?=NcsI_9i_sEkby|y4 z4!knK=pe-{?^1sERf6s#N`!=HKj5sO96^e}m-@g#@;zUnvg<99w|pM+;+xQub4Sfc z9KhV@jd(D!x0_=x?BwXvA5bj_0jHsI{pn^yGP~~;Jat{UD_T@*ul2@7*je$_($q-tz^_Oncb6l(0v=2~v=0Bi+6Rd+mA(Klv)&*7sGrPo)KC z>ja3}hSD(!JgNt`FmhxUi7Q3z_%WAk0@?0p}1{Uedu@v>RH zUm(x=G#X@|TvUb&^uB$7u4lK194zbf9};_&#RJr0wQ;EdJbTn$~ro zX>t-4r$=fsS&%YIsfcy;O{DJq4Yl)6FmU2l6di&3ztt>5EqXtQ*)osxqhDfVpcntx zQS^qU$KfiT!f@c>fXn??Pn{4u9e3_;#4aGS|xG-@-2DH1xs*met_}s{xwu8V1@O9NrUa8QKNQb z3uE28v3LH0#5E6NUvxuQoSa3FXMNVlviM$&u|2&UeQ6uruk509WEdmqU^loS;rVnh zer3lR)cmlmSMqTer&N(|(Id-AE);?$IrYb)n|k)Or-J-A=5@DW&%a0r)K0uEnuA{| zG5X%?jBnn6ZaFmEatF!PSE3mbCWY-m%?z+tUXHn7G5*nmXicq?JpT}==Uq$X&}4q# z!7Djd+L)Q)LTnc1c?}AEb zPe5eLwPFo#w1nwewBJ6TMUStd^;#L>6<=J8Lf#1vm`#w@UZsq^;!*-5gGPUJ@*C=q zAAI2{j{V)2sSXUPPmjFwCQJV1za_bPT~r4NUrW8Q6=UHNst0#tPL*d5eAR8xtQP$7 z5j9p^-Mta_+Iz9DegLz5d6>S;V9a<2Q0*(x|G_~HJ+pz~_ob~@#ZJ5EuB9gYriJnA z;f}rx@pxlZRpiUAZeZcVD_D5nGMpAE@>PL+`2DKFKMNrRyIMst`)Cb0MeY%XX7WBk zrRN|6&p%1&SQpNGsec5FZ{5n^(@)|qy9iA=$S@VqZ5zFzIb;H=RwF~4g4a3-f2Njc zQrBMVKgjsgU%}eFk;K*aV_$v;Mw77W%}mVL0${D;F(h{|-2)T{hB0j$r#XFM z>+M2A4mMp=frw6}Q#jvIKUpI1@_YbE8kN@nc^!2`nCTzu-7W{Ia z>Yn%TkL<+Tw-M(CX&bInBgZoh=1g7FJ@)cWx}M+8=+Q0=5;zTMG*b^x{b_LME20n? za?97K3>PsRgSH#yvG9@QwBNJ@Gc5y{l?m&*v)uRvz8JrbKT<@m6eBIr`gUanyLAz% zOD?B;cpt$?FJ2H}*gDy3Z$P(GAKh*g|6Ujmi-1|_<)4QaJqSlcbs1E$68NP&rH#)~ zQM6=t;$HVC#==!IYVCMNIe_v|f!<&2<=6|`8QgjlzgWddrqCr%iH5(3pz*8`vO(StbG_@E`C)#VaqLldn} z{2GQ?qkL$Wnhi)@egnBXAB>*U6GmRkk5lP87L^Z-aEIW0!52z39W9w5s8#U?x+uQ! z9jXU6VPAPKi7W5HXr38u!x`ZKq)pcU{y~ntv_rkG3>7hK11sYyl8zwor^BJI4sl)A zsphMcdW+Z%7E3<6ToH8SE@?+sG~xBdomt1rb6U`ay(g1l_?@?LZrq16ZwYAP0Oa{7 zl3Z~y_HX|Ss=Y_h3#0Gp zzo_WKTd!?p(POKafBRxAX+tVnuIdD%pL@oyplKM{9Ag`|U_AFj7X0QPqp41`IL6Q^ znz?zHYVfA+FiVlcl#@55Iw-@hJx{f-pY*CF1acx@Cc?qJW9FLGz)ijH z9zvX?7qJ)1=l#g=<=OXOM`)cxR3hk|bqgp}n@0?oGx#)l}r{WMYc?KB2K-2N_MhGMMncWvtG1mgvYw&ADW_r*R4Vv=z37cO=KbCu zkht;QDF2^uf~#>&nS&@rMlZxQ!gQ1-s8$(z<+~jF+E>tgAFClVIgV+nL{7bN{Gnq6 zV}sL{sAXC^03m?T6XB}vwA$Mdf(jifgJr6tRnn`nijPldxZPH1Lk1ruSr z^A=)MN1);dK)J7k<{B*f;!3r#*Kko2x}`;@{xrTRKLNg9A$9++st^{hM6EiAH_(gM za}>Yt7=C{*wZ3lkUYvp9rouV=k9%j8GKArKP%IFXi)hVlxc5GWd)h||xY*dB1S|Wkg*o--EA=bhbm;3*hRM-=v zlIWoUZ)BL>AAFg|K97H1z~2XMNG(p0}(BYR~lE$ipaNaI)0=&3@| zpcbBdDi<$LVCJb8GUY^6!w%@FM(mX>Sj#V_cH`anVTzQ@Rg{(QFjPJSti9$3$tZ0v>e~* zjh+`Zs~fcuCmMbtYUfqSZ+U@Z-~2m@+cu+1A3fnnY>j_p5Ix&~bNgesH{Fk!YyDW4Ccs{GHNg${V|@4yrI)@+g*MA#qamS%isck4@^@2e20{WBj{+ zj<;j_+@z=Yw9Z{KLmj>Hy>+gHQjA za&ISwjNiI0i3J@b7A?i@SVCgSDv~RgE5{HP{k)Iy7feho>RWmt^---fM)~MY#y@ zxQ{XMdh08N(J+!$#+tvD^b^0&Xz{<`A3lJVOik;m{+S|n0B3^Z+iTO=1Jrf@#PD6b z${729ww0~l{(#Q64$-)*O>Nkn62hNSt*QcBFgAwQ)5G|t57h~6wYHI1x`O2DHKeat zPjclNG&2`X>Wxm=)~g$myw6hUJw)%fzsktlzrY(AQI&TiCDh-xnheY|I%IN5HD}~i z>gmEHKF8ufgoI@MO(d_rTbLP67{4a}OGVL1)W%gTMNwBr8^K(735i?oqwxK|Bk(*G z9Zc_g;PeqYfU|6uP0#7Rdbll4hLV_O=YBl;Tl*CHhO)W`j;N=69_S; zn?e_IOtrr+mCsPEr`p#`xx1Uu_us{S<|&eEFQM_aJ4sz}Bc>~q-ubYDaGc~LJWb7P z6ZZUt)W-AFiY3hEX7zPbR(%^1B(tGG32(xrLr(uv0X>_;U3Wd2N@XPMufKd*{Nfn- zUGL%-^0>=ZVb5O{IW%zsWi%~?v+7zZi{8N7w>_MFm?elEz^p)BMoUgHhw)>*?EcXQ zZ2!R~#=FK;Ax#Y5t9n?bg?@^WYCWCC6*i?&17BwBt3I9|vgInb7ue`R#1Z&-gM$pc z{xT!)zD0W7l{DY;C=J)%4rXga?_G)J`3vX?o3_vWE{TOpIQrNB9j~v4)Vvnb4Gxy9 z@&|A^FO*8+8C zR>W*ugtcTP)jgZl+<geFykR1e<<--EtEO<@+LO_{X2AT>Oz;5>9nOH7Fknn zFdJ!3&&RP&zL_Tb_zmq%RI2?1GP^ItrHZb+My2dit$6r;pmKK2H8G{4U#&9oi`N+6 z@GiOA@1*7N-^5*Z31~7;P^UO6t6D(zhDTWBrWpP9|G+5>DH(HLqKm1Frp2+yP*Sc1 zJ=GkgaFVh}iexhI+>hyd<|k-1U(Gv=ZQg|T^}lB6zg>cn5wg*dC@n~%x6TXc%;cJ5 z2M}X=7`~;Z<$Aa5W7{|1XYcd7sE$=hG&mvOvj#~kMK;kyLbGu!_tdAYsvGL)rnKJ- zGz=Uy@-Id&wSgMtl1I7hQ7zT*s**C;s)z26kJ0zjC&};FPV2AyFEripAez+>r3CV` zi=g>ruDD0F^Tz)Ezfc<*Ld(cL{}i@UrFSpJD*Fp6^Qp_J*#y-wN(c6Z^F}i8V;C4o zpGsF3#e)Y)U$!W$2h>-QMVC+phN?4b_T!TfJAfI7P~=hyd_hfTaDNwje)1uEpW4pI z(P4DQBGDo(w*XVONxNCHi3Uu=R(c7(U;Tt19Thlh0lo}5O@QKIunqO*1~ygMf-HKJ z3KhzwnqtSX+$6=rhv@u^KcRB;Agxb)5wk%AH|zPdZ1@deufC1sV}p!+{m-FTKy#B5 zhHOss<0l1@?#R!UapI4xWP50qi`CMit}W0!3{#_8DqweXV5vGq@yozD~ns}8n z9kBzLF_>80Pf!{f<-l{B*!1-c4DRez8V}1Q=-{Qb4 z`v@vNPD4V4`(mW7ktCbUk+f6FSmkq*NtmWOAQA7X*p_MPSd9*D#$tT5LVlz~O=bvm zgZ^iprZO_d!aw-GlUTGO6g!D_{WZ-Xb^B-WI}cI#`EwYJ%>=S$E!%_Ac@WM$OVXHg zr~8(z#p2;37)SS#ZkP|6JXvzOkK!)Bg!#Y!&l%hBCjR(1$&1&Ky5yRO!Ku#F>`)!( zqOfTS@anm(+R7f$qn3mrisWntNA zMn_5%Ori2_XXNLvV&xi{|9k%&OO|dF$DbgNo^2&@?Pn(x=fJtTfPYIbg?C@1^y*It zhKIumyt8ovYU$8GH%x|Ke~Hv(SI~Il-JxR*qTL2fB9l^d+SgBhCF%g=D0lB?;F%|3 zco;n=HI3PW*a1vCNTMw#zC*24;^~m_F^wW)q^bBmgD*a>H~`yz^*1rI+2{loBg;p^C{eT`p+7c8 zZV~DGzKGqnfUzI^HNnU*npEM>27Z9mkjC42l%8+?J$5cfa_zc^xhd2eRIAD#A@Gnq zKSyo!D81kLYsNNipsAx-)mo+t{rqGgb^y}}>cnv%FzMfMh^^mvhy5??BHvfUO4+!r zp~A7H*<=!p>eM$317Gs|v)+=Kd&2z<$5cOYK;^Q9%A&px5?f0AZA0I$~$Cs zR;O-Vx>+$N%f|&YT%0TKqgE}F|JGlI#2shRe^<3d$HGh{C>+>F;oyFfYs3Le*yvPF z*F7#%7Wh?)`!>?|qrYMB#TPIW32dREmlDYAi2fB~2QVF=B3sEd4e~u>?E29rwtw$E z26y*kC2f+;E`jEwYX)gMOUBLN7;?jE_`WwScavmbNSr{XA))5yy-bRRn{UT%U+~d6 zdS!%?x!y}3QSR!cu>F08UVoP2;ltphaGM&j?az%{k`6OFYAgi!)7TReV6HT~QXaGKE81Ae694TJ;=dFMxPCyD~iJ(N=)t8XF?LLf@ zP<@p@67hJW{Stt9Vwh|2q~E5;tx890N#U3Dyc4L50E zFI<4t))7S?wdnb8U~I#S^!@M~1mz0l?k+XzEYX0GYQS`DQfX5SNX?ewUmdXnIPZuS ziBxr2Ze*YS9Y@&w^^NR*ZU@DI0#4e&NJ#-u!`2-#$t+1LizU^4YWQc9g9`Os(W6%N z&z}sTrcqOTem?0-F2QM^H-QdJ-r1B6@1*N*|BAw{?P$75!}T}O{`ud+YMVcSS-FTg zZxP1Q71VZb4<`)I1d<~8HLRqAyR-u%k@zSr83AJ-yvNAT-@uS!qtJv2vyl<*O{CMd zA|b8IB+Mqn4&Z#__%M%6zGs-d&unJv*WYLOz#yh;;kKq!4NBKc74B!<1}wu?r@i+{ zQ+%g@bOZc~k5{OXXtJ1p$0AaT&XZRD99%kZ{rK+BxD+?JXxFifnLc63M5r@9jr2QW^h z_m~=D5;fvbz$A=bWLq(nb)q53S!VsQ131r!hIuNZWsdxO6WhMGfunC7MAvkjoDf}# zVQ3_+w3^~`l_GAS+InXS)ds9VwN%A0blPrcWAWq5X}f7Tx^bQyfZ8MQ)yb~O^qgwr z)sIn&TA`2pwyo%H5<}(yY97^|V-#iJrjSRsrCUIht6Lb7|DUxCm|TZ}o@oeKjE_eE zHnq|ymEIo3SS(a;V)#zNQVd0oYlqv4vn=$lBX$7i&G1dNOw+&Ps4DR7`oShjqh*rq zY1KB9dr`)A(@xmam+if9eD_TAd^zm`wfZX#AhRS($NfuK^vFu=hGg^&m8j}{9w6b2 z?D>23!k&J7;6|8u?e=!cT}LqS@TLE58dy!uO7Z!lG+i~NEOgdW_=;H6L>D3wHOW_Z zBnIAi0li$Hp(UxR{UYLOnFfZT;R!e4EIj$^h#kO`LWX&S?80Z{U_bkw-L9ti`nGke z@!j;o1{GEsQr%a>JdLWlFJ|x6&Ol=zBHam>IXz497zcixYll9pWQ z#O9t~pnR+|oC-XvL_FLx(33JVA=e;mKVc`JHa3czG)b8WG;ur;C!uUR&UVBOU@D@|L9Bf0)9K?WEF4#Yb?aPC!ll%74clQ2p^Wf@(p9{=Sf(2;;DX z`?F=;?^6&vfGL42;TR0;>}1nd-(vr>+bQ&yNaS4XbU4LlYYy3TBPlzDq1#bguX@_i zrmW|R9jF4|NJ;gl8`k7l_`ot2+_#KGOFBB~3llEBt?>+qaY0wTXt- zh2gfJ8eFO&mfV%4OaRsCL zC&dp{mCRT;;TNd^%@M>7U`n74&`c-`*gzsDV_6%?Eg+^>vP@k><_?Jf)MUC5$`SCd)Zie>~8PS83@XT*;u^nQVV-W-4!m61+UJKRtI zQ{Ts3aVbv6%7`OCqLREC{?UvCsOuIb-MS%4^wLWCz}pNw^CMCTS#6GF*v>)|Ks^(` zv`?TZ2{Xy}sfJ=Ca|y8nm`W9)l82tMa9fgUX~#AbWD_}3Rz`hKttL}^!RZ^m9GrfD zH|~WJWjUMX+ZM6tp%t`T(-BFMN0W5NFJuBD&Az}te;S68&d8J%RbqR5RA_c|-!xyIB6qW8B9Qo`2N~QZKjjI=(Q!6eUg8G@;=)CuNBGf`Z+z{_$!9qehasy8OyQGZU{;_Aqnw?9$cKGKd~@?AyQ^4Nbn<* zm^)q|b^ud?@F+1fiw38O*6chL?uUvu^;7@!j4&)t1r)mr_=PIzrCH`bu#|-lE+w

wdbQh`1uEJ`X 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/icons/musicbrainz_l.png b/frontend/src/assets/icons/musicbrainz_l.png new file mode 100644 index 0000000000000000000000000000000000000000..1b864f73d96abce4ffaaca5f06f63c3eea949ec6 GIT binary patch literal 28243 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 XzN0QoiK&L=8-@7D>RBtquOw9xHv*L> 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%0DX2T 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

<$=am2R@`l7Uh zch30v%Aex=PCtlA1ap86&lNuj_k;7+$tRy2$QhP7{Ei*S2hzk(l)i&f$FJD0>{}(2 zi1?*5X3R(}exC6w`cUfl;p)|^<+|&xla?)8%4w&aW@iBA1AT<uvXpCdoHg8eyvK_B+p z{1L+YK<j#cWY`R~B8t z0dDua!-=y}>q8~*oM%z`npiu`eNC>ktk+t|0=gM!s z`Nr~j+hClXg#l`xxwXVml6qmvMi=#b;f4_~~*0K2La~FLyvI8rNVh zJ9Ow^@pQMo7<=;72y?@l_vu@Il8a_6ZNx3Os$@w%^HN#{uKZx*9xquq<4- z(B^>rICo%(C6oJv9^+1Km$+aq2<6M+y%|}OV$9^3(Cw@{^;T{>Tr$`|i6o zHp7Muvv|f2Km3r3z47w<4{vO!*^#7%>>bwbd+)s`W5FeYhZXc7Kd$3KoY`j$EGuJ@sUk3arcs#U9IYk~E` zeFWn4@4WL)?0qigIyC-S*Pk(n{Bik`(W6J(7oI-7jAv5bD6;+%_g{o<@RL|Qd3Be^ zy5WW!GWa@KuD$l!VBfmNG5ar3|F|~MC;4LTfnXP~_2?n)KCvGfHf(73EKWG#1bN|w z7otr~$3^r{R{a|#;O_aHb=FzJ-5PS+=mplmnl)>LJOH+t_*|SGjM2YY?N5|H`MqTh)3x;b`F^b_|ek|2xyg@0=QgWNB93v@lP z9hW;H2hYAtf*k$_GNkgqu&vx7Bp-@T^2#f(*u4g~7vlJ*@W0>>>`VW%L;}yt@W1h) z@ul`Nf?TeKbG@%U- zc)=6i#3=|%)KLuL6uBb@m9=_D#mTCFR_nQD2go~R{x|BxK`kvDss8Imd?xN>Vn;yU zDdQV;6sa2qe$U{JBlk7q?ut{!H|kJV(b1@+?;|Pa-UGm%c4vwaBSu(0+u1DY_%x7% zwjEWj4RhztwYKoM`|i8#UiHm4-z-z6Oc8uC&M6M4OB>oM{*%7p$KwOwgYcbuPxue? z|K*oo7U$DIgTBy)wrcNIxpS#&PhK8h?()koHyZ3sbaNPJLtEwt9uBzcG*Is__UqTr z?iE1`nWA|>+PL`*w|8{-zXZ;o_yL^%xbF;YcMf*#!}7bfJiqwj3p>xz4ttJWCs&DI z3pn}R_(kWO`<9$}$iL$MaxP&mIIo4rk2wg>pApwTef#z`TZ;dR9~Fkk{Kc*x#-DQ# z=l_{AX9mq<){lx{Yq7NJm-fKk?4J?}76Hv~|875CgL=B7i&P^Q7L=eP7Ja(!)TIq= F{{xWc&%*!! diff --git a/frontend/src/assets/icons/songlink_d.png b/frontend/src/assets/icons/songlink_d.png new file mode 100644 index 0000000000000000000000000000000000000000..b9887347c7d5e5e8bf106898ab4feb5df9b5ce5b GIT binary patch literal 15310 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%0A3i9>f48YLC>Wa|MEBo`vMYZG>g*yD!mL$P`k9t5 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/icons/songstats.png b/frontend/src/assets/icons/songstats.png index fc8a223965fd45e827bfb2260d4f0e00c4c6805a..ae111fc413359f01307dc5c104f8f14c29ca6935 100644 GIT binary patch literal 40971 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 B({TU* literal 12084 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ÐD_1KZC6DB#jMJ=>D(_&znvE1a!*N^YZ|}L4 zewv*7(T!ohqZY4s)g3T;+DAYpa9xtL!SOzy5xc)1#e+OiQR27G= znBl(c*D5<%`7=N@`o!t^+z}yZCq2ZmZl=Ofme|VbzVjJtsZV@)#6^W~7pSt#@ZvR1 z`AI6stLapXMog#T<%-j98`0a*(Nc5Cv^zw!zuY~IlgJrSwXpw=s3pU9 zWy#Z)40rNF)GRjfx!R&`2%)U^XN3H_1yxs7R?f!O&J3*F{pjr?&sEv*Nf*ZND@gO{ zLXQouvTB^8^v?&=wbyTJyfBt`i?E?^?wW*MW<-<2-an60VXCnnZiL>vZRXq1?7(nS qag}*@9e*mLyab^35QQ2$5(CQ8*(=m_SNQiC;QqEYsX24??tcLx^-y>K literal 0 HcmV?d00001 diff --git a/frontend/src/assets/icons/tidal_l.png b/frontend/src/assets/icons/tidal_l.png new file mode 100644 index 0000000000000000000000000000000000000000..7397386dfdce598955c34c18c9d76ed0c446e6c7 GIT binary patch literal 3937 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

- openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}> + openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
SpotiFLAC Next @@ -251,7 +251,7 @@ export function AboutPage() {
)}
- openExternal("https://github.com/afkarxyz/SpotiDownloader")}> + openExternal("https://github.com/spotbye/SpotiDownloader")}>
SpotiDownloader @@ -382,11 +382,10 @@ export function AboutPage() { SpotubeDL{" "} - SpotubeDL + SpotubeDL.com - Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus - with High Quality. + Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus. diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index a71e280..1c03e6f 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { parseTemplate, type TemplateData } from "@/lib/settings"; +import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; interface AlbumInfoProps { albumInfo: { @@ -52,6 +53,7 @@ interface AlbumInfoProps { downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; isBulkDownloadingLyrics?: boolean; + isMetadataLoading?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (id: string) => void; @@ -75,8 +77,52 @@ interface AlbumInfoProps { onTrackClick?: (track: TrackMetadata) => void; onBack?: () => void; } -export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) { +export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) { const settings = getSettings(); + const albumArtistNames = splitArtistNames(albumInfo.artists); + const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", "; + const fetchedTrackCount = trackList.length; + const totalTrackCount = albumInfo.total_tracks; + const showStreamingProgress = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount; + const clickableAlbumArtists = (() => { + const artistsByName = new Map(); + for (const track of trackList) { + const clickableTrackArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); + for (const artist of clickableTrackArtists) { + const normalizedName = artist.name.trim().toLowerCase(); + if (!normalizedName || !artist.external_urls || artistsByName.has(normalizedName)) { + continue; + } + artistsByName.set(normalizedName, artist); + } + } + return albumArtistNames.map((name) => { + const normalizedName = name.trim().toLowerCase(); + const matchedArtist = artistsByName.get(normalizedName); + if (matchedArtist) { + return { + ...matchedArtist, + name, + }; + } + if (albumArtistNames.length === 1 && albumInfo.artist_id && albumInfo.artist_url) { + return { + id: albumInfo.artist_id, + name, + external_urls: albumInfo.artist_url, + }; + } + return { + id: "", + name, + external_urls: "", + }; + }); + })(); const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false); const handleDownloadAlbumCover = async () => { if (!albumInfo.images) @@ -162,18 +208,25 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT

Album

{albumInfo.name}

- {onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? ( onArtistClick({ - id: albumInfo.artist_id!, - name: albumInfo.artists, - external_urls: albumInfo.artist_url!, - })}> - {albumInfo.artists} - ) : ({albumInfo.artists})} + + {clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => ( + {onArtistClick && artist.external_urls ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {index < clickableAlbumArtists.length - 1 && artistSeparator} + )) : albumInfo.artists} + {albumInfo.release_date} - {albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"} + {showStreamingProgress + ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` + : `${Math.max(totalTrackCount, fetchedTrackCount).toLocaleString()} ${Math.max(totalTrackCount, fetchedTrackCount) === 1 ? "track" : "tracks"}`}
diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index d1bb34f..674ccd4 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; +import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons"; import { useApiStatus } from "@/hooks/useApiStatus"; export function ApiStatusTab() { const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus(); @@ -12,12 +12,12 @@ export function ApiStatusTab() {
-
+
{sources.map((source) => { const status = statuses[source.id] || "idle"; return (
- {source.type === "tidal" ? : source.type === "amazon" ? : } + {source.type === "tidal" ? : source.type === "amazon" ? : source.type === "lrclib" ? : source.type === "musicbrainz" ? : }

{source.name}

diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index 987d5ca..9b77060 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -66,6 +66,7 @@ interface ArtistInfoProps { downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; isBulkDownloadingLyrics?: boolean; + isMetadataLoading?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (id: string) => void; @@ -94,7 +95,7 @@ interface ArtistInfoProps { onTrackClick?: (track: TrackMetadata) => void; onBack?: () => void; } -export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) { +export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) { const [downloadingHeader, setDownloadingHeader] = useState(false); const [downloadingAvatar, setDownloadingAvatar] = useState(false); const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState(null); @@ -102,6 +103,17 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums"); const [activeAlbumFilter, setActiveAlbumFilter] = useState("all"); const displayedAlbumCount = artistInfo.total_albums || albumList.length; + const fetchedAlbumCount = albumList.length; + const totalAlbumCount = artistInfo.total_albums || fetchedAlbumCount; + const totalTrackCount = albumList.reduce((sum, album) => sum + (album.total_tracks || 0), 0); + const fetchedTrackCount = trackList.length; + const albumCountLabel = isMetadataLoading && totalAlbumCount > 0 && fetchedAlbumCount < totalAlbumCount + ? `${fetchedAlbumCount.toLocaleString()} / ${totalAlbumCount.toLocaleString()} albums` + : `${displayedAlbumCount.toLocaleString()} ${displayedAlbumCount === 1 ? "album" : "albums"}`; + const resolvedTrackCount = totalTrackCount > 0 ? totalTrackCount : fetchedTrackCount; + const trackCountLabel = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount + ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` + : `${resolvedTrackCount.toLocaleString()} ${resolvedTrackCount === 1 ? "track" : "tracks"}`; const albumFilterCounts = useMemo(() => { const counts = new Map(); counts.set("all", (albumList || []).length); @@ -367,9 +379,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort )}
- {displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"} + {albumCountLabel} - {trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"} + {trackCountLabel} {artistInfo.genres.length > 0 && (<> {artistInfo.genres.join(", ")} @@ -420,9 +432,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort )}
- {displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"} + {albumCountLabel} - {trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"} + {trackCountLabel} {artistInfo.genres.length > 0 && (<> {artistInfo.genres.join(", ")} diff --git a/frontend/src/components/AvailabilityLinks.tsx b/frontend/src/components/AvailabilityLinks.tsx new file mode 100644 index 0000000..58791d9 --- /dev/null +++ b/frontend/src/components/AvailabilityLinks.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import type { TrackAvailability } from "@/types/api"; +import { openExternal } from "@/lib/utils"; +import { AmazonAvailabilityIcon, QobuzAvailabilityIcon, TidalAvailabilityIcon } from "./PlatformIcons"; +interface AvailabilityLinkEntry { + id: string; + found: boolean; + url?: string; + icon: ReactNode; +} +function getAvailabilityLinkEntries(availability: TrackAvailability): AvailabilityLinkEntry[] { + const tidalUrl = availability.tidal_url?.trim() || ""; + const qobuzUrl = availability.qobuz_url?.trim() || ""; + const amazonUrl = availability.amazon_url?.trim() || ""; + return [ + { + id: "tidal", + found: tidalUrl !== "", + url: tidalUrl, + icon: , + }, + { + id: "qobuz", + found: qobuzUrl !== "", + url: qobuzUrl, + icon: , + }, + { + id: "amazon", + found: amazonUrl !== "", + url: amazonUrl, + icon: , + }, + ]; +} +export function hasAvailabilityLinks(availability?: TrackAvailability): boolean { + if (!availability) { + return false; + } + return getAvailabilityLinkEntries(availability).some((entry) => entry.found); +} +export function AvailabilityLinks({ availability }: { + availability?: TrackAvailability; +}) { + if (!availability) { + return

Check Availability

; + } + const entries = getAvailabilityLinkEntries(availability); + return (
+ {entries.map((entry) => entry.found ? () : (
+ {entry.icon} + + Not Found + +
))} +
); +} diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx index b0c8a31..5deba40 100644 --- a/frontend/src/components/FileManagerPage.tsx +++ b/frontend/src/components/FileManagerPage.tsx @@ -36,6 +36,8 @@ interface FileMetadata { track_number: number; disc_number: number; year: string; + upc?: string; + isrc?: string; } type TabType = "track" | "lyric" | "cover"; const FORMAT_PRESETS: Record -

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}

+

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}, {"{isrc}"}

@@ -571,7 +573,7 @@ export function FileManagerPage() {

- Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac + Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09").replace(/\{isrc\}/g, "USUM71801234")}.flac

)} @@ -660,6 +662,8 @@ export function FileManagerPage() {
Track{metadataInfo.track_number || "-"}
Disc{metadataInfo.disc_number || "-"}
Year{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
+
UPC{metadataInfo.upc || "-"}
+
ISRC{metadataInfo.isrc || "-"}
) : (
No metadata available
)} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 6378151..9004e26 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -19,7 +19,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) { - diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index 3e98ea0..e944837 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -1,11 +1,13 @@ -import amazonMusicIcon from "../assets/icons/amazon-music.png"; -import qobuzIcon from "../assets/icons/qobuz.png"; -import tidalIcon from "../assets/icons/tidal.png"; -const PLATFORM_ICON_URLS = { - tidal: tidalIcon, - qobuz: qobuzIcon, - amazon: amazonMusicIcon, -} as const; +import amazonMusicIcon from "../assets/icons/amzn.png"; +import lrclibIcon from "../assets/icons/lrclib.png"; +import musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.png"; +import musicBrainzLightIcon from "../assets/icons/musicbrainz_l.png"; +import qobuzIcon from "../assets/icons/qbz.png"; +import songlinkDarkIcon from "../assets/icons/songlink_d.png"; +import songlinkLightIcon from "../assets/icons/songlink_l.png"; +import songstatsIcon from "../assets/icons/songstats.png"; +import tidalDarkIcon from "../assets/icons/tidal_d.png"; +import tidalLightIcon from "../assets/icons/tidal_l.png"; type PlatformIconProps = { className?: string; }; @@ -48,14 +50,48 @@ function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" } .join(" "); return {alt}; } +function ThemedPlatformIcon({ lightSrc, darkSrc, alt, className = "w-4 h-4", defaultClassName = "" }: { + lightSrc: string; + darkSrc: string; + alt: string; + className?: string; + defaultClassName?: string; +}) { + const cleanedClassName = sanitizeClassName(className); + const statusClasses = getStatusClasses(className); + const wrapperClassName = [ + cleanedClassName || "w-4 h-4", + "relative inline-flex shrink-0", + !hasRoundedClass(cleanedClassName) ? defaultClassName : "", + statusClasses, + ] + .filter(Boolean) + .join(" "); + return + + + ; +} export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; } export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; + return ; +} +export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function MusicBrainzIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function SonglinkIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; +} +export function SongstatsIcon({ className = "w-4 h-4" }: PlatformIconProps) { + return ; } export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { return diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index 50af90e..bf5f3ef 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { parseTemplate, type TemplateData } from "@/lib/settings"; +import { buildPlaylistFolderName } from "@/lib/playlist"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; interface PlaylistInfoProps { playlistInfo: { @@ -58,6 +59,7 @@ interface PlaylistInfoProps { downloadingCoverTrack?: string | null; isBulkDownloadingCovers?: boolean; isBulkDownloadingLyrics?: boolean; + isMetadataLoading?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; onToggleTrack: (id: string) => void; @@ -86,9 +88,14 @@ interface PlaylistInfoProps { onTrackClick: (track: TrackMetadata) => void; onBack?: () => void; } -export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) { +export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) { const settings = getSettings(); + const playlistName = playlistInfo.owner.name; + const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName); const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false); + const fetchedTrackCount = trackList.length; + const totalTrackCount = playlistInfo.tracks.total; + const showStreamingProgress = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount; const handleDownloadPlaylistCover = async () => { if (!playlistInfo.cover) return; @@ -96,17 +103,16 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel try { const os = settings.operatingSystem; let outputDir = settings.downloadPath; - const playlistName = playlistInfo.owner.name; const placeholder = "__SLASH_PLACEHOLDER__"; const templateData: TemplateData = { artist: "", album: "", album_artist: "", title: playlistName.replace(/\//g, placeholder), - playlist: playlistName.replace(/\//g, placeholder), + playlist: playlistFolderName.replace(/\//g, placeholder), }; - if (settings.createPlaylistFolder && playlistName) { - outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + if (settings.createPlaylistFolder && playlistFolderName) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistFolderName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { const folderPath = parseTemplate(settings.folderTemplate, templateData); @@ -157,7 +163,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
{playlistInfo.cover && (
- {playlistInfo.owner.name} + {playlistName}
@@ -172,7 +178,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel

Playlist

-

{playlistInfo.owner.name}

+

{playlistName}

{playlistInfo.description && (

{playlistInfo.description}

)}
@@ -181,7 +187,9 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
- {playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"} + {showStreamingProgress + ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` + : `${Math.max(totalTrackCount, fetchedTrackCount).toLocaleString()} ${Math.max(totalTrackCount, fetchedTrackCount) === 1 ? "track" : "tracks"}`} {playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"} @@ -234,7 +242,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
- +
); } diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 60b0218..04e01e7 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -13,9 +13,7 @@ import { themes, applyTheme } from "@/lib/themes"; import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { ApiStatusTab } from "./ApiStatusTab"; -import { AmazonIcon, QobuzIcon, TidalIcon } from "./PlatformIcons"; -import songlinkIcon from "@/assets/icons/songlink.ico"; -import songstatsIcon from "@/assets/icons/songstats.png"; +import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons"; interface SettingsPageProps { onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onResetRequest?: (resetFn: () => void) => void; @@ -245,13 +243,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin - Songlink + Songlink - Songstats + Songstats @@ -568,7 +566,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin .replace(/\{track\}/g, "01") .replace(/\{disc\}/g, "1") .replace(/\{year\}/g, "2018") - .replace(/\{date\}/g, "2018-02-09")} + .replace(/\{date\}/g, "2018-02-09") + .replace(/\{isrc\}/g, "USUM71801234")} /

)} @@ -584,6 +583,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+
+ setTempSettings((prev) => ({ + ...prev, + playlistOwnerFolderName: checked, + }))}/> + +
+
setTempSettings((prev) => ({ ...prev, @@ -604,6 +613,16 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
+
+ setTempSettings((prev) => ({ + ...prev, + redownloadWithSuffix: checked, + }))}/> + +
+
@@ -676,7 +695,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin .replace(/\{track\}/g, "01") .replace(/\{disc\}/g, "1") .replace(/\{year\}/g, "2018") - .replace(/\{date\}/g, "2018-02-09")} + .replace(/\{date\}/g, "2018-02-09") + .replace(/\{isrc\}/g, "USUM71801234")} .flac

)} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b6b35e1..13e88b1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -40,7 +40,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { } }; const handleOpenIssues = () => { - openExternal("https://github.com/afkarxyz/SpotiFLAC/issues"); + openExternal("https://github.com/spotbye/SpotiFLAC/issues"); handleIssuesDialogChange(false); }; const getAnimatedItemHandlers = (iconRef: RefObject) => ({ diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index 5929992..a238eed 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -1,31 +1,84 @@ -import { X, Minus, Maximize, SlidersHorizontal, Info, Globe } from "lucide-react"; +import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { getSettings, updateSettings } from "@/lib/settings"; +import { fetchCurrentIPInfo } from "@/lib/api"; +import type { CurrentIPInfo } from "@/types/api"; import { openExternal } from "@/lib/utils"; -import { useState, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; +const IP_INFO_REFRESH_INTERVAL_MS = 30000; +const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([ + "AF", + "IO", + "CF", + "CN", + "CU", + "ER", + "IR", + "MM", + "KP", + "RU", + "SO", + "SS", + "SD", + "SY", + "TM", + "YE", +]); export function TitleBar() { - const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false); + const [currentIPInfo, setCurrentIPInfo] = useState(null); + const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false); + const [currentIPInfoError, setCurrentIPInfoError] = useState(""); + const [showIPAddress, setShowIPAddress] = useState(false); + const currentIPInfoRef = useRef(null); useEffect(() => { - const settings = getSettings(); - if (settings) { - setUseSpotFetchAPI(settings.useSpotFetchAPI || false); + currentIPInfoRef.current = currentIPInfo; + }, [currentIPInfo]); + const loadCurrentIPInfo = async (options?: { + silent?: boolean; + }) => { + const silent = options?.silent ?? false; + if (!silent) { + setIsLoadingCurrentIPInfo(true); + setCurrentIPInfoError(""); } - const handleSettingsUpdate = (event: any) => { - const updatedSettings = event.detail; - if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') { - setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI); + try { + const info = await fetchCurrentIPInfo(); + setCurrentIPInfo(info); + setCurrentIPInfoError(""); + } + catch (error) { + if (!silent || !currentIPInfoRef.current) { + setCurrentIPInfo(null); + setCurrentIPInfoError(error instanceof Error ? error.message : "Unable to detect IP"); } - }; - window.addEventListener('settingsUpdated', handleSettingsUpdate); - return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate); - }, []); - const handleSpotFetchAPIToggle = () => { - const newValue = !useSpotFetchAPI; - setUseSpotFetchAPI(newValue); - updateSettings({ useSpotFetchAPI: newValue }); + } + finally { + if (!silent) { + setIsLoadingCurrentIPInfo(false); + } + } }; + useEffect(() => { + void loadCurrentIPInfo(); + }, []); + useEffect(() => { + const intervalId = window.setInterval(() => { + void loadCurrentIPInfo({ silent: true }); + }, IP_INFO_REFRESH_INTERVAL_MS); + const handleFocus = () => { + if (document.visibilityState === "hidden") { + return; + } + void loadCurrentIPInfo({ silent: true }); + }; + window.addEventListener("focus", handleFocus); + document.addEventListener("visibilitychange", handleFocus); + return () => { + window.clearInterval(intervalId); + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleFocus); + }; + }, []); const handleMinimize = () => { WindowMinimise(); }; @@ -35,6 +88,9 @@ export function TitleBar() { const handleClose = () => { Quit(); }; + const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || ""; + const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : ""; + const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode); return (<>
@@ -46,26 +102,35 @@ export function TitleBar() { - +
- SpotFetch API - - - - - - -

Spotify Blocked Countries:

-

Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen

-
-
-
+ Network + {isSpotifyBlockedCountry && ( + (Blocked by Spotify) + )} +
+
+
+
+ {detectedFlagPath ? ({detectedCountryCode}) : ()} + + {isLoadingCurrentIPInfo + ? "Detecting..." + : currentIPInfo + ? showIPAddress + ? `${currentIPInfo.ip} - ${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}` + : `${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}` + : "Unavailable"} + +
+ {currentIPInfo && !isLoadingCurrentIPInfo && ()} +
+ {!isLoadingCurrentIPInfo && !currentIPInfo && currentIPInfoError && (
+ IP detection unavailable +
)}
- - - Use SpotFetch API - {useSpotFetchAPI ? "✓" : ""} - openExternal("https://afkarxyz.qzz.io")} className="gap-2"> diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index eac6d31..4ea8e7a 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -4,8 +4,9 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; +import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; +import { buildClickableArtists } from "@/lib/artist-links"; interface TrackInfoProps { track: TrackMetadata & { album_name: string; @@ -31,10 +32,22 @@ interface TrackInfoProps { onCheckAvailability?: (spotifyId: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; onOpenFolder: () => void; + onAlbumClick?: (album: { + id: string; + name: string; + external_urls: string; + }) => void; + onArtistClick?: (artist: { + id: string; + name: string; + external_urls: string; + }) => void; onBack?: () => void; } -export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) { +export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onAlbumClick, onArtistClick, onBack, }: TrackInfoProps) { const { playPreview, loadingPreview, playingTrack } = usePreview(); + const hasAlbumClick = !!(onAlbumClick && track.album_id && track.album_url); + const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); const formatDuration = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -69,13 +82,30 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {track.is_explicit && (E)} {isSkipped ? () : isDownloaded ? () : isFailed ? () : null}
-

{track.artists}

+

+ {clickableArtists.length > 0 ? clickableArtists.map((artist, index) => ( + {onArtistClick ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {index < clickableArtists.length - 1 && ", "} + )) : track.artists} +

Album

-

{track.album_name}

+

{hasAlbumClick ? ( onAlbumClick?.({ + id: track.album_id!, + name: track.album_name, + external_urls: track.album_url!, + })}> + {track.album_name} + ) : (track.album_name)}

{track.plays && (

Total Plays

@@ -135,15 +165,11 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {track.spotify_id && onCheckAvailability && ( - - {availability ? (
- - - -
) : (

Check Availability

)} + +
)} {isDownloaded && ( diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 3fefc06..c161814 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -5,8 +5,9 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; +import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; +import { buildClickableArtists } from "@/lib/artist-links"; interface TrackListProps { tracks: TrackMetadata[]; searchQuery: string; @@ -172,6 +173,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa return plays; return num.toLocaleString(); }; + const getAvailabilityButtonIcon = (spotifyId?: string) => { + if (!spotifyId) { + return ; + } + if (checkingAvailabilityTrack === spotifyId) { + return ; + } + const availability = availabilityMap?.get(spotifyId); + if (!availability) { + return ; + } + if (hasAvailabilityLinks(availability)) { + return ; + } + return ; + }; return (
@@ -233,29 +250,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.spotify_id && skippedTracks.has(track.spotify_id) ? () : track.spotify_id && downloadedTracks.has(track.spotify_id) ? () : track.spotify_id && failedTracks.has(track.spotify_id) ? () : null}
- {track.artists_data && track.artists_data.length > 0 ? ((() => { - const artistNames = track.artists.split(", ").map(name => name.trim()); - return artistNames.map((name, i) => { - const artistData = track.artists_data![i]; - const hasArtistData = artistData && artistData.id && artistData.external_urls; - return ( - {onArtistClick && hasArtistData ? ( onArtistClick({ - id: artistData.id, - name: name, - external_urls: artistData.external_urls, - })}> - {name} - ) : (name)} - {i < artistNames.length - 1 && ", "} - ); - }); - })()) : onArtistClick && track.artist_id && track.artist_url ? ( onArtistClick({ - id: track.artist_id!, - name: track.artists, - external_urls: track.artist_url!, - })}> - {track.artists} - ) : (track.artists)} + {(() => { + const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); + if (clickableArtists.length === 0) { + return track.artists; + } + return clickableArtists.map((artist, i) => ( + {onArtistClick ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {i < clickableArtists.length - 1 && ", "} + )); + })()}
@@ -323,15 +333,11 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.spotify_id && onCheckAvailability && ( - - {availabilityMap?.has(track.spotify_id) ? (
- - - -
) : (

Check Availability

)} + +
)}
diff --git a/frontend/src/hooks/useApiStatus.ts b/frontend/src/hooks/useApiStatus.ts index 589a730..24a8b8b 100644 --- a/frontend/src/hooks/useApiStatus.ts +++ b/frontend/src/hooks/useApiStatus.ts @@ -11,6 +11,6 @@ export function useApiStatus() { return { ...state, sources: API_SOURCES, - refreshAll: checkAllApiStatuses, + refreshAll: () => checkAllApiStatuses(true), }; } diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index c835705..e195108 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -13,9 +13,6 @@ export function useAvailability() { setError("No Spotify ID provided"); return null; } - if (availabilityMap.has(spotifyId)) { - return availabilityMap.get(spotifyId)!; - } setChecking(true); setCheckingTrackId(spotifyId); setError(null); @@ -41,7 +38,7 @@ export function useAvailability() { setChecking(false); setCheckingTrackId(null); } - }, [availabilityMap]); + }, []); const getAvailability = useCallback((spotifyId: string) => { return availabilityMap.get(spotifyId); }, [availabilityMap]); diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 0a94439..ea773c6 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -12,6 +12,7 @@ interface CheckFileExistenceRequest { album_name?: string; album_artist?: string; release_date?: string; + isrc?: string; track_number?: number; disc_number?: number; position?: number; @@ -31,6 +32,26 @@ interface FileExistenceResult { const CheckFilesExistence = (outputDir: string, rootDir: string, tracks: CheckFileExistenceRequest[]): Promise => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, rootDir, tracks); const SkipDownloadItem = (itemID: string, filePath: string): Promise => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); const CreateM3U8File = (playlistName: string, outputDir: string, filePaths: string[]): Promise => (window as any)["go"]["main"]["App"]["CreateM3U8File"](playlistName, outputDir, filePaths); +const GetTrackISRC = (spotifyId: string): Promise => (window as any)["go"]["main"]["App"]["GetTrackISRC"](spotifyId); +async function resolveTemplateISRC(settings: { + folderTemplate?: string; + filenameTemplate?: string; +}, spotifyId?: string): Promise { + if (!spotifyId) { + return ""; + } + const folderTemplate = settings.folderTemplate || ""; + const filenameTemplate = settings.filenameTemplate || ""; + if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) { + return ""; + } + try { + return await GetTrackISRC(spotifyId); + } + catch { + return ""; + } +} export function useDownload(region: string) { const [downloadProgress, setDownloadProgress] = useState(0); const [isDownloading, setIsDownloading] = useState(false); @@ -81,11 +102,13 @@ export function useDownload(region: string) { const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId || id); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, date: releaseDate, @@ -117,6 +140,7 @@ export function useDownload(region: string) { album_name: albumName, album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, + isrc: resolvedTemplateISRC || undefined, track_number: finalTrackNumber || spotifyTrackNumber || 0, disc_number: spotifyDiscNumber || 0, position: trackNumberForTemplate, @@ -193,6 +217,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -240,6 +265,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, @@ -286,6 +312,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, @@ -350,6 +377,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_single_genre: settings.useSingleGenre, @@ -395,11 +423,13 @@ export function useDownload(region: string) { const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, date: releaseDate, @@ -468,6 +498,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -515,6 +546,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -563,6 +595,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, @@ -624,6 +657,7 @@ export function useDownload(region: string) { spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, spotify_total_discs: spotifyTotalDiscs, + isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, use_first_artist_only: settings.useFirstArtistOnly, diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts index 900ee3c..9c58700 100644 --- a/frontend/src/hooks/useLyrics.ts +++ b/frontend/src/hooks/useLyrics.ts @@ -5,6 +5,26 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; +const GetTrackISRC = (spotifyId: string): Promise => (window as any)["go"]["main"]["App"]["GetTrackISRC"](spotifyId); +async function resolveTemplateISRC(settings: { + folderTemplate?: string; + filenameTemplate?: string; +}, spotifyId?: string): Promise { + if (!spotifyId) { + return ""; + } + const folderTemplate = settings.folderTemplate || ""; + const filenameTemplate = settings.filenameTemplate || ""; + if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) { + return ""; + } + try { + return await GetTrackISRC(spotifyId); + } + catch { + return ""; + } +} export function useLyrics() { const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState(null); const [downloadedLyrics, setDownloadedLyrics] = useState>(new Set()); @@ -28,11 +48,13 @@ export function useLyrics() { const yearValue = releaseDate?.substring(0, 4); const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName; const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, spotifyId); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: position, year: yearValue, date: releaseDate, @@ -61,6 +83,7 @@ export function useLyrics() { album_name: albumName, album_artist: displayAlbumArtist, release_date: releaseDate, + isrc: resolvedTemplateISRC || undefined, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", track_number: settings.trackNumber, @@ -129,11 +152,13 @@ export function useLyrics() { const yearValue = track.release_date?.substring(0, 4); const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist; + const resolvedTemplateISRC = await resolveTemplateISRC(settings, id); const templateData: TemplateData = { artist: displayArtist?.replace(/\//g, placeholder), album: track.album_name?.replace(/\//g, placeholder), album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: track.name?.replace(/\//g, placeholder), + isrc: resolvedTemplateISRC?.replace(/\//g, placeholder), track: trackPosition, year: yearValue, date: track.release_date, @@ -161,6 +186,7 @@ export function useLyrics() { album_name: track.album_name, album_artist: displayAlbumArtist, release_date: track.release_date, + isrc: resolvedTemplateISRC || undefined, output_dir: outputDir, filename_format: settings.filenameTemplate || "{title}", track_number: settings.trackNumber, diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 80700e7..18e57ef 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -1,18 +1,18 @@ import { useEffect, useRef, useState } from "react"; -import { getSettings } from "@/lib/settings"; import { fetchSpotifyMetadata } from "@/lib/api"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { logger } from "@/lib/logger"; -import { AddFetchHistory } from "../../wailsjs/go/main/App"; +import { AddFetchHistory, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime"; import type { SpotifyMetadataResponse } from "@/types/api"; export function useMetadata() { const [loading, setLoading] = useState(false); const [metadata, setMetadata] = useState(null); + const [showVpnAdviceDialog, setShowVpnAdviceDialog] = useState(false); + const [fetchFailureReason, setFetchFailureReason] = useState(""); const loadingToastId = useRef(null); const fetchedCount = useRef(0); const currentName = useRef(""); - const [showApiModal, setShowApiModal] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; @@ -20,6 +20,23 @@ export function useMetadata() { external_urls: string; } | null>(null); const [pendingArtistName, setPendingArtistName] = useState(null); + const showFetchFailureAdvice = (errorMsg: string) => { + setFetchFailureReason(errorMsg); + setShowVpnAdviceDialog(true); + }; + const resolveArtistUrlBySearch = async (artistName: string): Promise => { + const query = artistName.trim(); + if (!query) { + return null; + } + const results = await SearchSpotifyByType({ + query, + search_type: "artist", + limit: 1, + offset: 0, + }); + return results[0]?.external_urls || null; + }; useEffect(() => { if (loading) { fetchedCount.current = 0; @@ -202,13 +219,8 @@ export function useMetadata() { catch (err) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; logger.error(`fetch failed: ${errorMsg}`); - const settings = getSettings(); - if (!settings.useSpotFetchAPI) { - setShowApiModal(true); - } - else { - toast.error(errorMsg); - } + toast.error(errorMsg); + showFetchFailureAdvice(errorMsg); } finally { setLoading(false); @@ -262,10 +274,17 @@ export function useMetadata() { external_urls: string; }) => { logger.debug(`artist clicked: ${artist.name}`); - const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + const resolvedArtistUrl = artist.external_urls.trim() || (await resolveArtistUrlBySearch(artist.name)) || ""; + if (!resolvedArtistUrl) { + toast.error(`Artist not found: ${artist.name}`); + return ""; + } + const artistUrl = resolvedArtistUrl.includes("/discography") + ? resolvedArtistUrl + : resolvedArtistUrl.replace(/\/$/, "") + "/discography/all"; setPendingArtistName(artist.name); await fetchMetadataDirectly(artistUrl); - return artistUrl; + return resolvedArtistUrl; }; const handleConfirmAlbumFetch = async () => { if (!selectedAlbum) @@ -303,13 +322,8 @@ export function useMetadata() { catch (err) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; logger.error(`fetch failed: ${errorMsg}`); - const settings = getSettings(); - if (!settings.useSpotFetchAPI) { - setShowApiModal(true); - } - else { - toast.error(errorMsg); - } + toast.error(errorMsg); + showFetchFailureAdvice(errorMsg); } finally { setLoading(false); @@ -319,6 +333,9 @@ export function useMetadata() { return { loading, metadata, + showVpnAdviceDialog, + setShowVpnAdviceDialog, + fetchFailureReason, showAlbumDialog, setShowAlbumDialog, selectedAlbum, @@ -328,8 +345,6 @@ export function useMetadata() { handleConfirmAlbumFetch, handleArtistClick, loadFromCache, - showApiModal, - setShowApiModal, resetMetadata: () => setMetadata(null), }; } diff --git a/frontend/src/lib/api-status.ts b/frontend/src/lib/api-status.ts index 379e886..dfe1126 100644 --- a/frontend/src/lib/api-status.ts +++ b/frontend/src/lib/api-status.ts @@ -1,4 +1,4 @@ -import { CheckAPIStatus } from "../../wailsjs/go/main/App"; +import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export interface ApiSource { @@ -19,6 +19,8 @@ export const API_SOURCES: ApiSource[] = [ { id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" }, { id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" }, { id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" }, + { id: "lrclib", type: "lrclib", name: "LRCLIB", url: "https://lrclib.net" }, + { id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" }, ]; type ApiStatusState = { isCheckingAll: boolean; @@ -30,6 +32,14 @@ let apiStatusState: ApiStatusState = { }; let activeCheckAll: Promise | null = null; const listeners = new Set<() => void>(); +type SpotiFLACUnifiedStatusResponse = { + tidal?: string; + qobuz_a?: string; + qobuz_b?: string; + qobuz_c?: string; + amazon?: string; + lrclib?: string; +}; function emitApiStatusChange() { for (const listener of listeners) { listener(); @@ -39,32 +49,37 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) apiStatusState = updater(apiStatusState); emitApiStatusChange(); } -async function checkSingleApiStatus(source: ApiSource): Promise { - setApiStatusState((current) => ({ - ...current, +function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus { + return value === "up" ? "online" : "offline"; +} +async function fetchUnifiedStatuses(forceRefresh: boolean): Promise> { + const response = await FetchUnifiedAPIStatus(forceRefresh); + const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse; + const tidalStatus = statusFromUnifiedValue(payload.tidal); + return { statuses: { - ...current.statuses, - [source.id]: "checking", + tidal1: tidalStatus, + tidal2: tidalStatus, + tidal3: tidalStatus, + tidal4: tidalStatus, + tidal5: tidalStatus, + tidal6: tidalStatus, + tidal7: tidalStatus, + qobuz1: statusFromUnifiedValue(payload.qobuz_a), + qobuz2: statusFromUnifiedValue(payload.qobuz_b), + qobuz3: statusFromUnifiedValue(payload.qobuz_c), + amazon1: statusFromUnifiedValue(payload.amazon), + lrclib: statusFromUnifiedValue(payload.lrclib), }, - })); + }; +} +async function checkMusicBrainzStatus(): Promise { try { - const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`); - setApiStatusState((current) => ({ - ...current, - statuses: { - ...current.statuses, - [source.id]: isOnline ? "online" : "offline", - }, - })); + const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz"); + return isOnline ? "online" : "offline"; } catch { - setApiStatusState((current) => ({ - ...current, - statuses: { - ...current.statuses, - [source.id]: "offline", - }, - })); + return "offline"; } } export function getApiStatusState(): ApiStatusState { @@ -84,20 +99,54 @@ export function hasApiStatusResults(): boolean { } export function ensureApiStatusCheckStarted(): void { if (!activeCheckAll && !hasApiStatusResults()) { - void checkAllApiStatuses(); + void checkAllApiStatuses(false); } } -export async function checkAllApiStatuses(): Promise { +export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise { if (activeCheckAll) { return activeCheckAll; } activeCheckAll = (async () => { + const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus])); setApiStatusState((current) => ({ ...current, isCheckingAll: true, + statuses: { + ...current.statuses, + ...checkingStatuses, + }, })); try { - await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source))); + const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([ + withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"), + checkMusicBrainzStatus(), + ]); + setApiStatusState((current) => { + const nextStatuses = { ...current.statuses }; + if (unifiedResult.status === "fulfilled") { + Object.assign(nextStatuses, unifiedResult.value.statuses); + } + else { + nextStatuses.tidal1 = "offline"; + nextStatuses.tidal2 = "offline"; + nextStatuses.tidal3 = "offline"; + nextStatuses.tidal4 = "offline"; + nextStatuses.tidal5 = "offline"; + nextStatuses.tidal6 = "offline"; + nextStatuses.tidal7 = "offline"; + nextStatuses.qobuz1 = "offline"; + nextStatuses.qobuz2 = "offline"; + nextStatuses.qobuz3 = "offline"; + nextStatuses.amazon1 = "offline"; + nextStatuses.lrclib = "offline"; + } + nextStatuses.musicbrainz = + musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline"; + return { + ...current, + statuses: nextStatuses, + }; + }); } finally { setApiStatusState((current) => ({ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e46a9ed..d2160f1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ -import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api"; -import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App"; +import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, CurrentIPInfo, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api"; +import { GetSpotifyMetadata, GetCurrentIPInfo, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App"; import { main } from "../../wailsjs/go/models"; export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise { const req = new main.SpotifyMetadataRequest({ @@ -24,6 +24,10 @@ export async function checkHealth(): Promise { time: new Date().toISOString(), }; } +export async function fetchCurrentIPInfo(): Promise { + const jsonString = await GetCurrentIPInfo(); + return JSON.parse(jsonString); +} export async function downloadLyrics(request: LyricsDownloadRequest): Promise { const req = new main.LyricsDownloadRequest(request); return await DownloadLyrics(req); diff --git a/frontend/src/lib/artist-links.ts b/frontend/src/lib/artist-links.ts new file mode 100644 index 0000000..8fc619a --- /dev/null +++ b/frontend/src/lib/artist-links.ts @@ -0,0 +1,42 @@ +import type { ArtistSimple } from "@/types/api"; +export interface ClickableArtist { + id: string; + name: string; + external_urls: string; +} +export function splitArtistNames(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + const parts = trimmed.split(/\s*[;,]\s*/).map((part) => part.trim()).filter(Boolean); + return parts.length > 0 ? parts : [trimmed]; +} +export function buildClickableArtists(artists: string, artistsData?: ArtistSimple[], fallbackArtistId?: string, fallbackArtistUrl?: string): ClickableArtist[] { + const names = splitArtistNames(artists); + if (names.length === 0) { + return []; + } + return names.map((name, index) => { + const artistData = artistsData?.[index]; + if (artistData && (artistData.id || artistData.external_urls)) { + return { + id: artistData.id || "", + name, + external_urls: artistData.external_urls || "", + }; + } + if (names.length === 1) { + return { + id: fallbackArtistId || "", + name, + external_urls: fallbackArtistUrl || "", + }; + } + return { + id: "", + name, + external_urls: "", + }; + }); +} diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts new file mode 100644 index 0000000..c2338c2 --- /dev/null +++ b/frontend/src/lib/playlist.ts @@ -0,0 +1,14 @@ +export function buildPlaylistFolderName(playlistName?: string, ownerName?: string, includeOwner = false): string { + const normalizedPlaylistName = playlistName?.trim() || ""; + if (!normalizedPlaylistName) { + return ""; + } + if (!includeOwner) { + return normalizedPlaylistName; + } + const normalizedOwnerName = ownerName?.trim() || ""; + if (!normalizedOwnerName || normalizedOwnerName.toLowerCase() === normalizedPlaylistName.toLowerCase()) { + return normalizedPlaylistName; + } + return `${normalizedPlaylistName}, ${normalizedOwnerName}`; +} diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index a1e1259..291e6ec 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -28,13 +28,13 @@ export interface Settings { autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string; autoQuality: "16" | "24"; allowFallback: boolean; - useSpotFetchAPI: boolean; - spotFetchAPIUrl: string; createPlaylistFolder: boolean; + playlistOwnerFolderName: boolean; createM3u8File: boolean; useFirstArtistOnly: boolean; useSingleGenre: boolean; embedGenre: boolean; + redownloadWithSuffix: boolean; separator: "comma" | "semicolon"; } export const FOLDER_PRESETS: Record { if (!('createPlaylistFolder' in parsed)) { parsed.createPlaylistFolder = true; } + if (!('playlistOwnerFolderName' in parsed)) { + parsed.playlistOwnerFolderName = false; + } if (!('createM3u8File' in parsed)) { parsed.createM3u8File = false; } @@ -333,11 +343,14 @@ export async function loadSettings(): Promise { parsed.useSingleGenre = false; } if (!('embedGenre' in parsed)) { - parsed.embedGenre = true; + parsed.embedGenre = false; } if (!('separator' in parsed)) { parsed.separator = "semicolon"; } + if (!('redownloadWithSuffix' in parsed)) { + parsed.redownloadWithSuffix = false; + } cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; return cachedSettings!; } @@ -360,6 +373,7 @@ export interface TemplateData { album?: string; album_artist?: string; title?: string; + isrc?: string; track?: number; disc?: number; year?: string; @@ -374,6 +388,7 @@ export function parseTemplate(template: string, data: TemplateData): string { result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist"); result = result.replace(/\{album\}/g, data.album || "Unknown Album"); result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist"); + result = result.replace(/\{isrc\}/g, data.isrc || ""); result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00"); result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1"); result = result.replace(/\{year\}/g, data.year || "0000"); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 03cd42d..48dc659 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -23,6 +23,8 @@ export interface TrackMetadata { artist_id?: string; artist_url?: string; artists_data?: ArtistSimple[]; + isrc?: string; + upc?: string; copyright?: string; publisher?: string; plays?: string; @@ -38,6 +40,7 @@ export interface AlbumInfo { release_date: string; artists: string; images: string; + upc?: string; batch?: string; } export interface AlbumResponse { @@ -116,7 +119,7 @@ export interface DownloadRequest { album_artist?: string; release_date?: string; cover_url?: string; - api_url?: string; + tidal_api_url?: string; output_dir?: string; audio_format?: string; folder_name?: string; @@ -134,6 +137,7 @@ export interface DownloadRequest { spotify_disc_number?: number; spotify_total_tracks?: number; spotify_total_discs?: number; + isrc?: string; copyright?: string; publisher?: string; spotify_url?: string; @@ -153,6 +157,12 @@ export interface HealthResponse { status: string; time: string; } +export interface CurrentIPInfo { + ip: string; + country: string; + country_code?: string; + source?: string; +} export interface TimeSlice { time: number; magnitudes: number[] | Float32Array; @@ -190,6 +200,7 @@ export interface LyricsDownloadRequest { album_name?: string; album_artist?: string; release_date?: string; + isrc?: string; output_dir?: string; filename_format?: string; track_number?: boolean; @@ -278,4 +289,6 @@ export interface AudioMetadata { track_number: number; disc_number: number; year: string; + upc?: string; + isrc?: string; } diff --git a/wails.json b/wails.json index a6b85a0..ff85543 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.1.3", + "productVersion": "7.1.4", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",